refactor: Configure subrepositorios

This commit is contained in:
rckrdmrd 2026-01-04 07:19:23 -06:00
parent d0f47896e7
commit dfa82a276e
158 changed files with 25 additions and 29643 deletions

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# SUBREPOSITORIOS
apps/
# Dependencias
node_modules/
# Build
dist/
# Environment
.env
!.env.example
# IDE
.idea/
.vscode/

9
.gitmodules vendored Normal file
View File

@ -0,0 +1,9 @@
# Subrepositorios de platform_marketing_content
[submodule "apps/backend"]
path = apps/backend
url = git@gitea-server:rckrdmrd/platform-marketing-content-backend.git
[submodule "apps/frontend"]
path = apps/frontend
url = git@gitea-server:rckrdmrd/platform-marketing-content-frontend.git

View File

@ -1,48 +0,0 @@
# PMC Backend Environment Variables
# Application
NODE_ENV=development
PORT=3111
API_PREFIX=api/v1
# Database
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=pmc_dev
DATABASE_USER=pmc_user
DATABASE_PASSWORD=pmc_secret_2024
DATABASE_SSL=false
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
JWT_REFRESH_EXPIRES_IN=30d
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# Storage (S3/MinIO)
STORAGE_ENDPOINT=localhost
STORAGE_PORT=9000
STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin
STORAGE_BUCKET=pmc-assets
STORAGE_USE_SSL=false
# ComfyUI
COMFYUI_URL=http://localhost:8188
COMFYUI_WS_URL=ws://localhost:8188/ws
# External APIs
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
# Rate Limiting
THROTTLE_TTL=60
THROTTLE_LIMIT=100
# CORS
CORS_ORIGINS=http://localhost:5173,http://localhost:3110,http://localhost:3111

View File

@ -1,26 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
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': ['warn', { argsIgnorePattern: '^_' }],
},
};

View File

@ -1,28 +0,0 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment files
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# Logs
logs/
*.log
npm-debug.log*
# Testing
coverage/
# OS
.DS_Store
Thumbs.db

View File

@ -1,7 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"printWidth": 100
}

View File

@ -1,37 +0,0 @@
# =============================================================================
# PMC Backend - Dockerfile
# =============================================================================
FROM node:20-alpine AS deps
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nestjs
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN mkdir -p /var/log/pmc && chown -R nestjs:nodejs /var/log/pmc
USER nestjs
EXPOSE 3111
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --spider -q http://localhost:3111/health || exit 1
CMD ["node", "dist/main.js"]

View File

@ -1,32 +0,0 @@
import type { Config } from 'jest';
const config: Config = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: [
'**/*.(t|j)s',
'!**/*.spec.ts',
'!**/*.interface.ts',
'!**/*.module.ts',
'!**/index.ts',
'!**/*.entity.ts',
'!main.ts',
],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
setupFilesAfterEnv: ['<rootDir>/__tests__/setup.ts'],
testTimeout: 30000,
verbose: true,
clearMocks: true,
resetMocks: true,
restoreMocks: true,
};
export default config;

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,98 +0,0 @@
{
"name": "@pmc/backend",
"version": "0.1.0",
"description": "Platform Marketing Content - Backend API",
"author": "PMC 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",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nestjs/bull": "^10.0.1",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.3.0",
"@nestjs/swagger": "^7.2.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.1",
"@nestjs/websockets": "^10.3.0",
"bcrypt": "^5.1.1",
"bull": "^4.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.11.3",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"socket.io": "^4.7.4",
"typeorm": "^0.3.19",
"uuid": "^9.0.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"@types/passport-jwt": "^4.0.0",
"@types/passport-local": "^1.0.38",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"jest": "^29.7.0",
"prettier": "^3.1.1",
"source-map-support": "^0.5.21",
"supertest": "^6.3.4",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.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",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1"
}
}
}

View File

@ -1,67 +0,0 @@
# Service Descriptor - Platform Marketing API
# Generado automáticamente durante migración
service:
name: marketing-api
type: backend_api
framework: express
runtime: node
version: "20"
description: "API de plataforma de marketing y contenido"
owner_agent: NEXUS-BACKEND
repository:
name: workspace-v1
path: projects/platform_marketing_content/apps/backend
main_branch: main
ports:
internal: 3110
registry_ref: projects.platform_marketing.services.api
protocol: http
domains:
registry_ref: projects.platform_marketing.domains
overrides:
local: api.marketing.localhost
database:
registry_ref: databases.platform_marketing
role: runtime
schemas:
- public
- content
- campaigns
docker:
dockerfile: Dockerfile
context: .
networks:
- marketing_${ENV:-local}
- infra_shared
labels:
traefik:
enable: true
rule: "Host(`api.marketing.localhost`)"
dependencies:
external:
- name: minio
purpose: "Almacenamiento de contenido"
port: 9000
- name: comfyui
purpose: "Generación de contenido"
port: 8188
healthcheck:
path: /health
interval: 30s
timeout: 10s
retries: 3
metadata:
created: "2025-12-26"
updated: "2025-12-26"
maintainers:
- tech-leader
status: active

View File

@ -1,64 +0,0 @@
import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
/**
* Test setup utilities for PMC backend
*/
// Global test timeout
jest.setTimeout(30000);
// Mock database configuration for testing
export const testDatabaseConfig = {
type: 'postgres' as const,
host: process.env.TEST_DB_HOST || 'localhost',
port: parseInt(process.env.TEST_DB_PORT, 10) || 5433,
username: process.env.TEST_DB_USERNAME || 'postgres',
password: process.env.TEST_DB_PASSWORD || 'postgres',
database: process.env.TEST_DB_NAME || 'pmc_test',
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: true,
dropSchema: true,
logging: false,
};
/**
* Create a testing module with TypeORM configured
*/
export const createTestingModule = async (imports: any[] = [], providers: any[] = []) => {
return Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env.test',
}),
TypeOrmModule.forRoot(testDatabaseConfig),
...imports,
],
providers,
}).compile();
};
/**
* Mock ConfigService for testing
*/
export const mockConfigService = {
get: jest.fn((key: string, defaultValue?: any) => {
const config = {
'jwt.secret': 'test-secret',
'jwt.expiresIn': '1h',
'database.host': 'localhost',
'database.port': 5433,
'app.port': 3000,
};
return config[key] || defaultValue;
}),
};
/**
* Clear all mocks after each test
*/
afterEach(() => {
jest.clearAllMocks();
});

View File

@ -1,71 +0,0 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { ThrottlerModule } from '@nestjs/throttler';
// Config
import { databaseConfig } from './config/database.config';
import { redisConfig } from './config/redis.config';
// Modules
import { AuthModule } from './modules/auth/auth.module';
import { TenantsModule } from './modules/tenants/tenants.module';
import { CrmModule } from './modules/crm/crm.module';
import { AssetsModule } from './modules/assets/assets.module';
import { ProjectsModule } from './modules/projects/projects.module';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
// Database
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: databaseConfig,
}),
// Redis & Bull Queues
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: redisConfig,
}),
// Rate Limiting
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
throttlers: [
{
ttl: config.get<number>('THROTTLE_TTL', 60) * 1000,
limit: config.get<number>('THROTTLE_LIMIT', 100),
},
],
}),
}),
// Feature Modules
AuthModule,
TenantsModule,
CrmModule,
AssetsModule,
ProjectsModule,
// GenerationModule,// TODO: Activar en BE-008
// AutomationModule,// TODO: Activar en BE-009
// AnalyticsModule, // TODO: Activar en BE-010
],
controllers: [],
providers: [],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Middleware global se configura aqui
}
}

View File

@ -1,18 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* Decorator para obtener el tenant_id del usuario actual.
* Atajo conveniente para @CurrentUser('tenantId').
*
* @example
* @Get()
* async findAll(@CurrentTenant() tenantId: string) {
* return this.clientService.findAllByTenant(tenantId);
* }
*/
export const CurrentTenant = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request.user?.tenantId;
},
);

View File

@ -1,36 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserData {
id: string;
email: string;
tenantId: string;
role: string;
}
/**
* Decorator para obtener el usuario actual del request.
*
* @example
* @Get('me')
* async getProfile(@CurrentUser() user: CurrentUserData) {
* return this.userService.findById(user.id);
* }
*
* // Obtener solo una propiedad
* @Get('tenant')
* async getTenant(@CurrentUser('tenantId') tenantId: string) {
* return this.tenantService.findById(tenantId);
* }
*/
export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserData | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserData;
if (!user) {
return null;
}
return data ? user[data] : user;
},
);

View File

@ -1,15 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/**
* Decorator para marcar rutas como publicas (sin autenticacion).
*
* @example
* @Public()
* @Get('health')
* healthCheck() {
* return { status: 'ok' };
* }
*/
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -1,28 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
/**
* Roles disponibles en el sistema PMC.
*/
export enum UserRole {
OWNER = 'owner',
ADMIN = 'admin',
CREATIVE = 'creative',
ANALYST = 'analyst',
VIEWER = 'viewer',
CLIENT_PORTAL = 'client_portal',
}
/**
* Decorator para restringir acceso por roles.
*
* @example
* @Roles(UserRole.OWNER, UserRole.ADMIN)
* @UseGuards(JwtAuthGuard, RolesGuard)
* @Delete(':id')
* async remove(@Param('id') id: string) {
* return this.service.remove(id);
* }
*/
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -1,70 +0,0 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
interface ErrorResponse {
statusCode: number;
timestamp: string;
path: string;
method: string;
message: string | string[];
error?: string;
}
/**
* Filtro global para manejar excepciones HTTP.
* Formatea respuestas de error de manera consistente.
*/
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string | string[] = 'Internal server error';
let error: string | undefined;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object') {
const responseObj = exceptionResponse as any;
message = responseObj.message || exception.message;
error = responseObj.error;
}
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(
`Unhandled exception: ${exception.message}`,
exception.stack,
);
}
const errorResponse: ErrorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message,
};
if (error) {
errorResponse.error = error;
}
response.status(status).json(errorResponse);
}
}

View File

@ -1,40 +0,0 @@
import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
/**
* Guard para autenticacion JWT.
* Aplica a todas las rutas excepto las marcadas con @Public().
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
// Verificar si la ruta es publica
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('Invalid or missing token');
}
return user;
}
}

View File

@ -1,38 +0,0 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY, UserRole } from '../decorators/roles.decorator';
/**
* Guard para verificar roles de usuario.
* Usar junto con @Roles() decorator.
*
* @example
* @Roles(UserRole.ADMIN)
* @UseGuards(JwtAuthGuard, RolesGuard)
* @Controller('admin')
* export class AdminController {}
*/
@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()],
);
// Si no hay roles definidos, permitir acceso
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user || !user.role) {
return false;
}
return requiredRoles.includes(user.role);
}
}

View File

@ -1,34 +0,0 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
/**
* Guard que verifica que el usuario pertenezca al tenant.
* Asegura que tenantId del token coincida con el contexto.
*/
@Injectable()
export class TenantMemberGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException('User not authenticated');
}
if (!user.tenantId) {
throw new ForbiddenException('User not associated with any tenant');
}
// Si hay un tenant_id en params, verificar que coincida
const paramTenantId = request.params?.tenantId || request.params?.tenant_id;
if (paramTenantId && paramTenantId !== user.tenantId) {
throw new ForbiddenException('Access denied to this tenant');
}
return true;
}
}

View File

@ -1,19 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig = (
configService: ConfigService,
): TypeOrmModuleOptions => ({
type: 'postgres',
host: configService.get<string>('DATABASE_HOST', 'localhost'),
port: configService.get<number>('DATABASE_PORT', 5432),
username: configService.get<string>('DATABASE_USER', 'pmc_user'),
password: configService.get<string>('DATABASE_PASSWORD', 'pmc_secret_2024'),
database: configService.get<string>('DATABASE_NAME', 'pmc_dev'),
ssl: configService.get<boolean>('DATABASE_SSL', false),
autoLoadEntities: true,
synchronize: configService.get<string>('NODE_ENV') === 'development',
logging: configService.get<string>('NODE_ENV') === 'development',
entities: ['dist/**/*.entity{.ts,.js}'],
migrations: ['dist/migrations/*{.ts,.js}'],
});

View File

@ -1,17 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { JwtModuleOptions } from '@nestjs/jwt';
export const jwtConfig = (configService: ConfigService): JwtModuleOptions => ({
secret: configService.get<string>('JWT_SECRET', 'default-secret-change-me'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN', '7d'),
},
});
export const jwtRefreshConfig = (configService: ConfigService) => ({
secret: configService.get<string>(
'JWT_REFRESH_SECRET',
'default-refresh-secret',
),
expiresIn: configService.get<string>('JWT_REFRESH_EXPIRES_IN', '30d'),
});

View File

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

View File

@ -1,19 +0,0 @@
import { ConfigService } from '@nestjs/config';
export interface StorageConfig {
endpoint: string;
port: number;
accessKey: string;
secretKey: string;
bucket: string;
useSSL: boolean;
}
export const storageConfig = (configService: ConfigService): StorageConfig => ({
endpoint: configService.get<string>('STORAGE_ENDPOINT', 'localhost'),
port: configService.get<number>('STORAGE_PORT', 9000),
accessKey: configService.get<string>('STORAGE_ACCESS_KEY', 'minioadmin'),
secretKey: configService.get<string>('STORAGE_SECRET_KEY', 'minioadmin'),
bucket: configService.get<string>('STORAGE_BUCKET', 'pmc-assets'),
useSSL: configService.get<boolean>('STORAGE_USE_SSL', false),
});

View File

@ -1,83 +0,0 @@
import { DocumentBuilder } from '@nestjs/swagger';
/**
* Swagger/OpenAPI Configuration for Platform Marketing Content
*/
export const swaggerConfig = new DocumentBuilder()
.setTitle('Platform Marketing Content API')
.setDescription(`
API para la plataforma SaaS de generación de contenido y CRM creativo.
## Características principales
- Autenticación JWT multi-tenant
- Gestión de clientes, marcas y productos (CRM)
- Proyectos y campañas de marketing
- Generación de contenido con IA
- Biblioteca de assets multimedia
- Automatización de flujos de trabajo
- Analytics y métricas de rendimiento
## Autenticación
Todos los endpoints requieren autenticación mediante Bearer Token (JWT).
El sistema es multi-tenant, cada usuario pertenece a un tenant específico.
## Multi-tenant
El tenant se identifica automáticamente desde el usuario autenticado.
Todos los datos están aislados por tenant.
`)
.setVersion('1.0.0')
.setContact(
'PMC Support',
'https://platformmc.com',
'support@platformmc.com',
)
.setLicense('Proprietary', '')
.addServer('http://localhost:3000', 'Desarrollo local')
.addServer('https://api.platformmc.com', 'Producción')
// Authentication
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'Authorization',
description: 'JWT token obtenido del endpoint de login',
in: 'header',
},
'JWT-auth',
)
// Tags organized by functional area
.addTag('Auth', 'Autenticación y sesiones de usuario')
.addTag('Tenants', 'Gestión de tenants (organizaciones)')
.addTag('CRM', 'CRM - Clientes, marcas y productos')
.addTag('Projects', 'Proyectos y campañas de marketing')
.addTag('Generation', 'Generación de contenido con IA')
.addTag('Assets', 'Biblioteca de assets y archivos multimedia')
.addTag('Automation', 'Flujos de trabajo y automatización')
.addTag('Analytics', 'Métricas, reportes y análisis de rendimiento')
.addTag('Templates', 'Plantillas de contenido y diseño')
.addTag('Collaboration', 'Colaboración y comentarios en tiempo real')
.addTag('Health', 'Health checks y monitoreo del sistema')
.build();
// Swagger UI options
export const swaggerUiOptions = {
customSiteTitle: 'Platform Marketing Content - API Documentation',
customCss: `
.swagger-ui .topbar { display: none }
.swagger-ui .info { margin: 50px 0; }
.swagger-ui .info .title { font-size: 36px; }
`,
swaggerOptions: {
persistAuthorization: true,
docExpansion: 'none',
filter: true,
showRequestDuration: true,
displayRequestDuration: true,
tagsSorter: 'alpha',
operationsSorter: 'alpha',
},
};

View File

@ -1,56 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { swaggerConfig, swaggerUiOptions } from './config/swagger.config';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Global prefix
const apiPrefix = configService.get<string>('API_PREFIX', 'api/v1');
app.setGlobalPrefix(apiPrefix);
// Security
app.use(helmet());
// CORS
const corsOrigins = configService.get<string>('CORS_ORIGINS', '');
app.enableCors({
origin: corsOrigins.split(',').map((origin) => origin.trim()),
credentials: true,
});
// Validation pipe global
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// Swagger documentation
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, document, swaggerUiOptions);
// Start server
const port = configService.get<number>('PORT', 3000);
await app.listen(port);
console.log(`
================================================
PMC Backend running on: http://localhost:${port}
Swagger docs: http://localhost:${port}/docs
API prefix: /${apiPrefix}
================================================
`);
}
bootstrap();

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Asset, AssetFolder } from './entities';
import { AssetService, FolderService } from './services';
import { AssetController, FolderController } from './controllers';
@Module({
imports: [TypeOrmModule.forFeature([Asset, AssetFolder])],
controllers: [AssetController, FolderController],
providers: [AssetService, FolderService],
exports: [AssetService, FolderService],
})
export class AssetsModule {}

View File

@ -1,136 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { AssetService } from '../services/asset.service';
import { CreateAssetDto, UpdateAssetDto } from '../dto';
import { AssetType, AssetStatus } from '../entities/asset.entity';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { TenantMemberGuard } from '@/common/guards/tenant-member.guard';
import { CurrentTenant } from '@/common/decorators/current-tenant.decorator';
import { PaginationDto } from '@/shared/dto/pagination.dto';
@ApiTags('Assets')
@Controller('assets')
@UseGuards(JwtAuthGuard, TenantMemberGuard)
@ApiBearerAuth('JWT-auth')
export class AssetController {
constructor(private readonly assetService: AssetService) {}
@Get()
@ApiOperation({ summary: 'Get all assets with pagination' })
@ApiQuery({ name: 'brandId', required: false })
@ApiQuery({ name: 'type', enum: AssetType, required: false })
@ApiQuery({ name: 'search', required: false })
@ApiResponse({ status: 200, description: 'List of assets' })
async findAll(
@CurrentTenant() tenantId: string,
@Query() pagination: PaginationDto,
@Query('brandId') brandId?: string,
@Query('type') type?: AssetType,
@Query('search') search?: string,
) {
return this.assetService.findAllPaginated(tenantId, {
...pagination,
brandId,
type,
search,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get asset by ID' })
@ApiResponse({ status: 200, description: 'Asset found' })
@ApiResponse({ status: 404, description: 'Asset not found' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.assetService.findById(tenantId, id);
}
@Get('brand/:brandId')
@ApiOperation({ summary: 'Get assets by brand' })
@ApiResponse({ status: 200, description: 'List of brand assets' })
async findByBrand(
@CurrentTenant() tenantId: string,
@Param('brandId', ParseUUIDPipe) brandId: string,
) {
return this.assetService.findByBrand(tenantId, brandId);
}
@Post()
@ApiOperation({ summary: 'Create new asset' })
@ApiResponse({ status: 201, description: 'Asset created' })
@ApiResponse({ status: 400, description: 'Bad request' })
async create(
@CurrentTenant() tenantId: string,
@Body() dto: CreateAssetDto,
) {
return this.assetService.create(tenantId, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Update asset' })
@ApiResponse({ status: 200, description: 'Asset updated' })
@ApiResponse({ status: 404, description: 'Asset not found' })
async update(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAssetDto,
) {
return this.assetService.update(tenantId, id, dto);
}
@Put(':id/status')
@ApiOperation({ summary: 'Update asset status' })
@ApiResponse({ status: 200, description: 'Asset status updated' })
@ApiResponse({ status: 404, description: 'Asset not found' })
async updateStatus(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body('status') status: AssetStatus,
) {
return this.assetService.updateStatus(tenantId, id, status);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete asset (soft delete)' })
@ApiResponse({ status: 204, description: 'Asset deleted' })
@ApiResponse({ status: 404, description: 'Asset not found' })
async delete(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.assetService.softDelete(tenantId, id);
}
@Delete('bulk')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete multiple assets' })
@ApiResponse({ status: 204, description: 'Assets deleted' })
async bulkDelete(
@CurrentTenant() tenantId: string,
@Body('ids') ids: string[],
) {
await this.assetService.bulkDelete(tenantId, ids);
}
}

View File

@ -1,125 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { FolderService } from '../services/folder.service';
import { CreateFolderDto, UpdateFolderDto } from '../dto';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { TenantMemberGuard } from '@/common/guards/tenant-member.guard';
import { CurrentTenant } from '@/common/decorators/current-tenant.decorator';
@ApiTags('Assets - Folders')
@Controller('assets/folders')
@UseGuards(JwtAuthGuard, TenantMemberGuard)
@ApiBearerAuth('JWT-auth')
export class FolderController {
constructor(private readonly folderService: FolderService) {}
@Get()
@ApiOperation({ summary: 'Get all folders' })
@ApiQuery({ name: 'brandId', required: false })
@ApiResponse({ status: 200, description: 'List of folders' })
async findAll(
@CurrentTenant() tenantId: string,
@Query('brandId') brandId?: string,
) {
return this.folderService.findAll(tenantId, brandId);
}
@Get('tree')
@ApiOperation({ summary: 'Get folder tree structure' })
@ApiQuery({ name: 'brandId', required: false })
@ApiResponse({ status: 200, description: 'Folder tree' })
async getTree(
@CurrentTenant() tenantId: string,
@Query('brandId') brandId?: string,
) {
return this.folderService.getTree(tenantId, brandId);
}
@Get('root')
@ApiOperation({ summary: 'Get root folders' })
@ApiQuery({ name: 'brandId', required: false })
@ApiResponse({ status: 200, description: 'List of root folders' })
async findRootFolders(
@CurrentTenant() tenantId: string,
@Query('brandId') brandId?: string,
) {
return this.folderService.findRootFolders(tenantId, brandId);
}
@Get(':id')
@ApiOperation({ summary: 'Get folder by ID' })
@ApiResponse({ status: 200, description: 'Folder found' })
@ApiResponse({ status: 404, description: 'Folder not found' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.folderService.findById(tenantId, id);
}
@Get('slug/:slug')
@ApiOperation({ summary: 'Get folder by slug' })
@ApiResponse({ status: 200, description: 'Folder found' })
@ApiResponse({ status: 404, description: 'Folder not found' })
async findBySlug(
@CurrentTenant() tenantId: string,
@Param('slug') slug: string,
) {
return this.folderService.findBySlug(tenantId, slug);
}
@Post()
@ApiOperation({ summary: 'Create new folder' })
@ApiResponse({ status: 201, description: 'Folder created' })
@ApiResponse({ status: 400, description: 'Bad request' })
async create(
@CurrentTenant() tenantId: string,
@Body() dto: CreateFolderDto,
) {
return this.folderService.create(tenantId, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Update folder' })
@ApiResponse({ status: 200, description: 'Folder updated' })
@ApiResponse({ status: 404, description: 'Folder not found' })
async update(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateFolderDto,
) {
return this.folderService.update(tenantId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete folder (soft delete)' })
@ApiResponse({ status: 204, description: 'Folder deleted' })
@ApiResponse({ status: 400, description: 'Cannot delete folder with subfolders' })
@ApiResponse({ status: 404, description: 'Folder not found' })
async delete(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.folderService.softDelete(tenantId, id);
}
}

View File

@ -1,2 +0,0 @@
export * from './asset.controller';
export * from './folder.controller';

View File

@ -1,95 +0,0 @@
import { IsString, IsOptional, IsEnum, IsUUID, IsArray, IsObject, IsInt, Min, IsUrl } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AssetType, AssetStatus } from '../entities/asset.entity';
export class CreateAssetDto {
@ApiProperty({ description: 'Asset name' })
@IsString()
name: string;
@ApiProperty({ description: 'Original file name' })
@IsString()
original_name: string;
@ApiPropertyOptional({ description: 'Brand ID', format: 'uuid' })
@IsOptional()
@IsUUID()
brand_id?: string;
@ApiPropertyOptional({ enum: AssetType, default: AssetType.IMAGE })
@IsOptional()
@IsEnum(AssetType)
type?: AssetType;
@ApiProperty({ description: 'MIME type' })
@IsString()
mime_type: string;
@ApiProperty({ description: 'File size in bytes' })
@IsInt()
@Min(0)
size: number;
@ApiProperty({ description: 'Asset URL' })
@IsUrl()
url: string;
@ApiPropertyOptional({ description: 'Thumbnail URL' })
@IsOptional()
@IsUrl()
thumbnail_url?: string;
@ApiPropertyOptional({ description: 'Image/Video width' })
@IsOptional()
@IsInt()
@Min(0)
width?: number;
@ApiPropertyOptional({ description: 'Image/Video height' })
@IsOptional()
@IsInt()
@Min(0)
height?: number;
@ApiPropertyOptional({ description: 'Duration in seconds for video/audio' })
@IsOptional()
@IsInt()
@Min(0)
duration?: number;
@ApiPropertyOptional({ description: 'Alternative text' })
@IsOptional()
@IsString()
alt_text?: string;
@ApiPropertyOptional({ description: 'Asset description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Tags', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({ description: 'Additional metadata' })
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
@ApiPropertyOptional({ enum: AssetStatus, default: AssetStatus.READY })
@IsOptional()
@IsEnum(AssetStatus)
status?: AssetStatus;
@ApiPropertyOptional({ description: 'Storage path' })
@IsOptional()
@IsString()
storage_path?: string;
@ApiPropertyOptional({ description: 'Storage provider (s3, gcs, local)' })
@IsOptional()
@IsString()
storage_provider?: string;
}

View File

@ -1,34 +0,0 @@
import { IsString, IsOptional, IsUUID, IsInt, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateFolderDto {
@ApiProperty({ description: 'Folder name' })
@IsString()
name: string;
@ApiPropertyOptional({ description: 'URL-friendly slug' })
@IsOptional()
@IsString()
slug?: string;
@ApiPropertyOptional({ description: 'Brand ID', format: 'uuid' })
@IsOptional()
@IsUUID()
brand_id?: string;
@ApiPropertyOptional({ description: 'Parent folder ID', format: 'uuid' })
@IsOptional()
@IsUUID()
parent_id?: string;
@ApiPropertyOptional({ description: 'Folder description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Sort order', default: 0 })
@IsOptional()
@IsInt()
@Min(0)
sort_order?: number;
}

View File

@ -1,4 +0,0 @@
export * from './create-asset.dto';
export * from './update-asset.dto';
export * from './create-folder.dto';
export * from './update-folder.dto';

View File

@ -1,6 +0,0 @@
import { PartialType, OmitType } from '@nestjs/swagger';
import { CreateAssetDto } from './create-asset.dto';
export class UpdateAssetDto extends PartialType(
OmitType(CreateAssetDto, ['original_name', 'mime_type', 'size', 'url', 'storage_path', 'storage_provider'] as const)
) {}

View File

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

View File

@ -1,67 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
ManyToOne,
JoinColumn,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantAwareEntity } from '@/shared/entities/tenant-aware.entity';
import { Brand } from '@/modules/crm/entities/brand.entity';
@Entity('asset_folders', { schema: 'assets' })
@Index(['tenant_id', 'brand_id', 'parent_id'])
@Index(['tenant_id', 'slug'], { unique: true })
export class AssetFolder extends TenantAwareEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid', { nullable: true })
brand_id: string | null;
@ManyToOne(() => Brand, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'brand_id' })
brand: Brand;
@Column('uuid', { nullable: true })
parent_id: string | null;
@ManyToOne(() => AssetFolder, (folder) => folder.children, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'parent_id' })
parent: AssetFolder;
@OneToMany(() => AssetFolder, (folder) => folder.parent)
children: AssetFolder[];
@Column({ length: 255 })
name: string;
@Column({ length: 255 })
slug: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'text', nullable: true })
path: string | null; // Full path like /root/subfolder/this
@Column({ type: 'int', default: 0 })
level: number; // Depth level in hierarchy
@Column({ type: 'int', default: 0 })
sort_order: number;
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updated_at: Date;
@Column({ type: 'timestamptz', nullable: true })
deleted_at: Date | null;
}

View File

@ -1,102 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantAwareEntity } from '@/shared/entities/tenant-aware.entity';
import { Brand } from '@/modules/crm/entities/brand.entity';
export enum AssetType {
IMAGE = 'image',
VIDEO = 'video',
DOCUMENT = 'document',
AUDIO = 'audio',
OTHER = 'other',
}
export enum AssetStatus {
UPLOADING = 'uploading',
PROCESSING = 'processing',
READY = 'ready',
ERROR = 'error',
}
@Entity('assets', { schema: 'assets' })
@Index(['tenant_id', 'brand_id'])
@Index(['tenant_id', 'type'])
export class Asset extends TenantAwareEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid', { nullable: true })
brand_id: string | null;
@ManyToOne(() => Brand, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'brand_id' })
brand: Brand;
@Column({ length: 255 })
name: string;
@Column({ length: 255 })
original_name: string;
@Column({ type: 'enum', enum: AssetType, default: AssetType.IMAGE })
type: AssetType;
@Column({ length: 100 })
mime_type: string;
@Column({ type: 'bigint' })
size: number;
@Column({ type: 'text' })
url: string;
@Column({ type: 'text', nullable: true })
thumbnail_url: string | null;
@Column({ type: 'int', nullable: true })
width: number | null;
@Column({ type: 'int', nullable: true })
height: number | null;
@Column({ type: 'int', nullable: true })
duration: number | null; // For video/audio in seconds
@Column({ type: 'text', nullable: true })
alt_text: string | null;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column('simple-array', { nullable: true })
tags: string[] | null;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;
@Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.READY })
status: AssetStatus;
@Column({ length: 255, nullable: true })
storage_path: string | null;
@Column({ length: 50, nullable: true })
storage_provider: string | null; // 's3', 'gcs', 'local'
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updated_at: Date;
@Column({ type: 'timestamptz', nullable: true })
deleted_at: Date | null;
}

View File

@ -1,2 +0,0 @@
export * from './asset.entity';
export * from './asset-folder.entity';

View File

@ -1,144 +0,0 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { Asset, AssetType, AssetStatus } from '../entities/asset.entity';
import { CreateAssetDto, UpdateAssetDto } from '../dto';
import { TenantAwareService } from '@/shared/services/tenant-aware.service';
import { PaginationParams, PaginatedResult } from '@/shared/dto/pagination.dto';
@Injectable()
export class AssetService extends TenantAwareService<Asset> {
constructor(
@InjectRepository(Asset)
private readonly assetRepository: Repository<Asset>,
) {
super(assetRepository);
}
async findAllPaginated(
tenantId: string,
pagination: PaginationParams & { brandId?: string; type?: AssetType; search?: string },
): Promise<PaginatedResult<Asset>> {
const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC', brandId, type, search } = pagination;
const queryBuilder = this.assetRepository
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.brand', 'brand')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.deleted_at IS NULL');
if (brandId) {
queryBuilder.andWhere('asset.brand_id = :brandId', { brandId });
}
if (type) {
queryBuilder.andWhere('asset.type = :type', { type });
}
if (search) {
queryBuilder.andWhere(
'(asset.name ILIKE :search OR asset.original_name ILIKE :search OR asset.alt_text ILIKE :search)',
{ search: `%${search}%` },
);
}
queryBuilder.orderBy(`asset.${sortBy}`, sortOrder);
const total = await queryBuilder.getCount();
const totalPages = Math.ceil(total / limit);
queryBuilder.skip((page - 1) * limit).take(limit);
const data = await queryBuilder.getMany();
return {
data,
meta: {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
async findById(tenantId: string, id: string): Promise<Asset> {
const asset = await this.assetRepository.findOne({
where: { id, tenant_id: tenantId, deleted_at: IsNull() },
relations: ['brand'],
});
if (!asset) {
throw new NotFoundException(`Asset with ID ${id} not found`);
}
return asset;
}
async findByBrand(tenantId: string, brandId: string): Promise<Asset[]> {
return this.assetRepository.find({
where: { tenant_id: tenantId, brand_id: brandId, deleted_at: IsNull() },
order: { created_at: 'DESC' },
});
}
async create(tenantId: string, dto: CreateAssetDto): Promise<Asset> {
const asset = this.assetRepository.create({
...dto,
tenant_id: tenantId,
});
return this.assetRepository.save(asset);
}
async update(tenantId: string, id: string, dto: UpdateAssetDto): Promise<Asset> {
const asset = await this.findById(tenantId, id);
Object.assign(asset, dto);
return this.assetRepository.save(asset);
}
async updateStatus(tenantId: string, id: string, status: AssetStatus): Promise<Asset> {
const asset = await this.findById(tenantId, id);
asset.status = status;
return this.assetRepository.save(asset);
}
async softDelete(tenantId: string, id: string): Promise<void> {
const asset = await this.findById(tenantId, id);
asset.deleted_at = new Date();
await this.assetRepository.save(asset);
}
async hardDelete(tenantId: string, id: string): Promise<void> {
const asset = await this.findById(tenantId, id);
await this.assetRepository.remove(asset);
}
async bulkDelete(tenantId: string, ids: string[]): Promise<void> {
await this.assetRepository
.createQueryBuilder()
.update(Asset)
.set({ deleted_at: new Date() })
.where('tenant_id = :tenantId', { tenantId })
.andWhere('id IN (:...ids)', { ids })
.execute();
}
getAssetTypeFromMimeType(mimeType: string): AssetType {
if (mimeType.startsWith('image/')) return AssetType.IMAGE;
if (mimeType.startsWith('video/')) return AssetType.VIDEO;
if (mimeType.startsWith('audio/')) return AssetType.AUDIO;
if (
mimeType.startsWith('application/pdf') ||
mimeType.startsWith('application/msword') ||
mimeType.startsWith('application/vnd.')
) {
return AssetType.DOCUMENT;
}
return AssetType.OTHER;
}
}

View File

@ -1,175 +0,0 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { AssetFolder } from '../entities/asset-folder.entity';
import { CreateFolderDto, UpdateFolderDto } from '../dto';
import { TenantAwareService } from '@/shared/services/tenant-aware.service';
@Injectable()
export class FolderService extends TenantAwareService<AssetFolder> {
constructor(
@InjectRepository(AssetFolder)
private readonly folderRepository: Repository<AssetFolder>,
) {
super(folderRepository);
}
async findAll(tenantId: string, brandId?: string): Promise<AssetFolder[]> {
const where: any = { tenant_id: tenantId, deleted_at: IsNull() };
if (brandId) {
where.brand_id = brandId;
}
return this.folderRepository.find({
where,
relations: ['children'],
order: { sort_order: 'ASC', name: 'ASC' },
});
}
async findRootFolders(tenantId: string, brandId?: string): Promise<AssetFolder[]> {
const where: any = {
tenant_id: tenantId,
parent_id: IsNull(),
deleted_at: IsNull(),
};
if (brandId) {
where.brand_id = brandId;
}
return this.folderRepository.find({
where,
relations: ['children'],
order: { sort_order: 'ASC', name: 'ASC' },
});
}
async findById(tenantId: string, id: string): Promise<AssetFolder> {
const folder = await this.folderRepository.findOne({
where: { id, tenant_id: tenantId, deleted_at: IsNull() },
relations: ['parent', 'children', 'brand'],
});
if (!folder) {
throw new NotFoundException(`Folder with ID ${id} not found`);
}
return folder;
}
async findBySlug(tenantId: string, slug: string): Promise<AssetFolder> {
const folder = await this.folderRepository.findOne({
where: { slug, tenant_id: tenantId, deleted_at: IsNull() },
relations: ['parent', 'children'],
});
if (!folder) {
throw new NotFoundException(`Folder with slug ${slug} not found`);
}
return folder;
}
async create(tenantId: string, dto: CreateFolderDto): Promise<AssetFolder> {
const slug = dto.slug || this.generateSlug(dto.name);
// Check for unique slug
const existing = await this.folderRepository.findOne({
where: { tenant_id: tenantId, slug, deleted_at: IsNull() },
});
if (existing) {
throw new BadRequestException(`Folder with slug "${slug}" already exists`);
}
let level = 0;
let path = `/${slug}`;
let parent: AssetFolder | null = null;
if (dto.parent_id) {
parent = await this.findById(tenantId, dto.parent_id);
level = parent.level + 1;
path = `${parent.path}/${slug}`;
}
const folder = this.folderRepository.create({
...dto,
slug,
level,
path,
tenant_id: tenantId,
});
return this.folderRepository.save(folder);
}
async update(tenantId: string, id: string, dto: UpdateFolderDto): Promise<AssetFolder> {
const folder = await this.findById(tenantId, id);
if (dto.slug && dto.slug !== folder.slug) {
const existing = await this.folderRepository.findOne({
where: { tenant_id: tenantId, slug: dto.slug, deleted_at: IsNull() },
});
if (existing && existing.id !== id) {
throw new BadRequestException(`Folder with slug "${dto.slug}" already exists`);
}
}
if (dto.parent_id && dto.parent_id !== folder.parent_id) {
// Prevent circular reference
if (dto.parent_id === id) {
throw new BadRequestException('Cannot set folder as its own parent');
}
const newParent = await this.findById(tenantId, dto.parent_id);
folder.level = newParent.level + 1;
folder.path = `${newParent.path}/${folder.slug}`;
}
Object.assign(folder, dto);
return this.folderRepository.save(folder);
}
async softDelete(tenantId: string, id: string): Promise<void> {
const folder = await this.findById(tenantId, id);
// Check if folder has children
const children = await this.folderRepository.find({
where: { tenant_id: tenantId, parent_id: id, deleted_at: IsNull() },
});
if (children.length > 0) {
throw new BadRequestException('Cannot delete folder with subfolders');
}
folder.deleted_at = new Date();
await this.folderRepository.save(folder);
}
async getTree(tenantId: string, brandId?: string): Promise<AssetFolder[]> {
const rootFolders = await this.findRootFolders(tenantId, brandId);
return this.buildTree(rootFolders, tenantId);
}
private async buildTree(folders: AssetFolder[], tenantId: string): Promise<AssetFolder[]> {
for (const folder of folders) {
if (folder.children && folder.children.length > 0) {
folder.children = await this.buildTree(folder.children, tenantId);
}
}
return folders;
}
private generateSlug(name: string): string {
return name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
}

View File

@ -1,2 +0,0 @@
export * from './asset.service';
export * from './folder.service';

View File

@ -1,240 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UnauthorizedException, ConflictException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { AuthService } from '../services/auth.service';
import { User, UserStatus } from '../entities/user.entity';
import { Session } from '../entities/session.entity';
import { TenantsService } from '../../tenants/services/tenants.service';
import { UserRole } from '../../../common/decorators/roles.decorator';
describe('AuthService', () => {
let service: AuthService;
let userRepository: Repository<User>;
let sessionRepository: Repository<Session>;
let jwtService: JwtService;
let tenantsService: TenantsService;
const mockUser: Partial<User> = {
id: '123e4567-e89b-12d3-a456-426614174000',
email: 'test@example.com',
password_hash: 'hashed_password',
first_name: 'Test',
last_name: 'User',
role: UserRole.VIEWER,
status: UserStatus.ACTIVE,
tenant_id: 'tenant_123',
last_login_at: new Date(),
};
const mockUserRepository = {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
const mockSessionRepository = {
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
};
const mockJwtService = {
signAsync: jest.fn(),
verify: jest.fn(),
};
const mockTenantsService = {
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: getRepositoryToken(User),
useValue: mockUserRepository,
},
{
provide: getRepositoryToken(Session),
useValue: mockSessionRepository,
},
{
provide: JwtService,
useValue: mockJwtService,
},
{
provide: TenantsService,
useValue: mockTenantsService,
},
],
}).compile();
service = module.get<AuthService>(AuthService);
userRepository = module.get<Repository<User>>(getRepositoryToken(User));
sessionRepository = module.get<Repository<Session>>(getRepositoryToken(Session));
jwtService = module.get<JwtService>(JwtService);
tenantsService = module.get<TenantsService>(TenantsService);
// Clear all mocks
jest.clearAllMocks();
});
describe('login', () => {
it('should successfully login a user with valid credentials', async () => {
const loginDto = {
email: 'test@example.com',
password: 'password123',
};
const hashedPassword = await bcrypt.hash('password123', 12);
const userWithHash = { ...mockUser, password_hash: hashedPassword };
mockUserRepository.findOne.mockResolvedValue(userWithHash);
mockUserRepository.save.mockResolvedValue(userWithHash);
mockJwtService.signAsync.mockResolvedValueOnce('access_token').mockResolvedValueOnce('refresh_token');
mockSessionRepository.create.mockReturnValue({});
mockSessionRepository.save.mockResolvedValue({});
const result = await service.login(loginDto);
expect(result.user).toBeDefined();
expect(result.tokens).toBeDefined();
expect(result.tokens.accessToken).toBe('access_token');
expect(result.tokens.refreshToken).toBe('refresh_token');
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
where: { email: loginDto.email },
});
});
it('should throw UnauthorizedException for invalid email', async () => {
const loginDto = {
email: 'nonexistent@example.com',
password: 'password123',
};
mockUserRepository.findOne.mockResolvedValue(null);
await expect(service.login(loginDto)).rejects.toThrow(UnauthorizedException);
await expect(service.login(loginDto)).rejects.toThrow('Invalid credentials');
});
it('should throw UnauthorizedException for invalid password', async () => {
const loginDto = {
email: 'test@example.com',
password: 'wrong_password',
};
const hashedPassword = await bcrypt.hash('correct_password', 12);
const userWithHash = { ...mockUser, password_hash: hashedPassword };
mockUserRepository.findOne.mockResolvedValue(userWithHash);
await expect(service.login(loginDto)).rejects.toThrow(UnauthorizedException);
await expect(service.login(loginDto)).rejects.toThrow('Invalid credentials');
});
it('should throw UnauthorizedException for inactive user', async () => {
const loginDto = {
email: 'test@example.com',
password: 'password123',
};
const hashedPassword = await bcrypt.hash('password123', 12);
const inactiveUser = {
...mockUser,
password_hash: hashedPassword,
status: UserStatus.INACTIVE
};
mockUserRepository.findOne.mockResolvedValue(inactiveUser);
await expect(service.login(loginDto)).rejects.toThrow(UnauthorizedException);
await expect(service.login(loginDto)).rejects.toThrow('Account is not active');
});
});
describe('register', () => {
it('should successfully register a new user', async () => {
const registerDto = {
email: 'newuser@example.com',
password: 'password123',
first_name: 'New',
last_name: 'User',
tenant_id: 'tenant_123',
role: UserRole.VIEWER,
};
mockUserRepository.findOne.mockResolvedValue(null);
mockUserRepository.create.mockReturnValue(mockUser);
mockUserRepository.save.mockResolvedValue(mockUser);
mockJwtService.signAsync.mockResolvedValueOnce('access_token').mockResolvedValueOnce('refresh_token');
mockSessionRepository.create.mockReturnValue({});
mockSessionRepository.save.mockResolvedValue({});
const result = await service.register(registerDto);
expect(result.user).toBeDefined();
expect(result.tokens).toBeDefined();
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
where: { email: registerDto.email, tenant_id: registerDto.tenant_id },
});
expect(mockUserRepository.save).toHaveBeenCalled();
});
it('should throw ConflictException if email already exists in tenant', async () => {
const registerDto = {
email: 'existing@example.com',
password: 'password123',
first_name: 'Existing',
last_name: 'User',
tenant_id: 'tenant_123',
role: UserRole.VIEWER,
};
mockUserRepository.findOne.mockResolvedValue(mockUser);
await expect(service.register(registerDto)).rejects.toThrow(ConflictException);
await expect(service.register(registerDto)).rejects.toThrow('Email already registered in this tenant');
});
});
describe('validateUser', () => {
it('should return user when valid payload is provided', async () => {
const payload = {
sub: mockUser.id,
email: mockUser.email,
tenantId: mockUser.tenant_id,
role: mockUser.role,
};
mockUserRepository.findOne.mockResolvedValue(mockUser);
const result = await service.validateUser(payload);
expect(result).toEqual(mockUser);
expect(mockUserRepository.findOne).toHaveBeenCalledWith({
where: { id: payload.sub },
});
});
it('should return null when user is not found', async () => {
const payload = {
sub: 'non-existent-id',
email: 'test@example.com',
tenantId: 'tenant_123',
role: UserRole.VIEWER,
};
mockUserRepository.findOne.mockResolvedValue(null);
const result = await service.validateUser(payload);
expect(result).toBeNull();
});
});
});

View File

@ -1,30 +0,0 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './controllers/auth.controller';
import { AuthService } from './services/auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { User } from './entities/user.entity';
import { Session } from './entities/session.entity';
import { jwtConfig } from '../../config/jwt.config';
import { TenantsModule } from '../tenants/tenants.module';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: jwtConfig,
}),
TypeOrmModule.forFeature([User, Session]),
TenantsModule,
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@ -1,99 +0,0 @@
import {
Controller,
Post,
Body,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { AuthService } from '../services/auth.service';
import { RegisterDto } from '../dto/register.dto';
import { LoginDto } from '../dto/login.dto';
import { AuthResponseDto } from '../dto/auth-response.dto';
import { Public } from '../../../common/decorators/public.decorator';
import { CurrentUser, CurrentUserData } from '../../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('register')
@ApiOperation({ summary: 'Registrar nuevo usuario' })
@ApiResponse({ status: 201, type: AuthResponseDto })
@ApiResponse({ status: 409, description: 'Email ya registrado' })
async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> {
const { user, tokens } = await this.authService.register(dto);
return {
user: {
id: user.id,
tenant_id: user.tenant_id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
avatar_url: user.avatar_url,
role: user.role,
status: user.status,
created_at: user.created_at,
},
tokens,
};
}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Iniciar sesion' })
@ApiResponse({ status: 200, type: AuthResponseDto })
@ApiResponse({ status: 401, description: 'Credenciales invalidas' })
async login(@Body() dto: LoginDto): Promise<AuthResponseDto> {
const { user, tokens } = await this.authService.login(dto);
return {
user: {
id: user.id,
tenant_id: user.tenant_id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
avatar_url: user.avatar_url,
role: user.role,
status: user.status,
created_at: user.created_at,
},
tokens,
};
}
@Post('logout')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Cerrar sesion' })
@ApiResponse({ status: 204, description: 'Sesion cerrada' })
async logout(
@CurrentUser() user: CurrentUserData,
@Body('refreshToken') refreshToken: string,
): Promise<void> {
await this.authService.logout(user.id, refreshToken);
}
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refrescar tokens' })
@ApiResponse({ status: 200, description: 'Tokens refrescados' })
@ApiResponse({ status: 401, description: 'Refresh token invalido' })
async refreshTokens(@Body('refreshToken') refreshToken: string) {
const tokens = await this.authService.refreshTokens(refreshToken);
return { tokens };
}
}

View File

@ -1,46 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class UserResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
tenant_id: string;
@ApiProperty()
email: string;
@ApiProperty()
first_name: string | null;
@ApiProperty()
last_name: string | null;
@ApiProperty()
avatar_url: string | null;
@ApiProperty()
role: string;
@ApiProperty()
status: string;
@ApiProperty()
created_at: Date;
}
export class TokensResponseDto {
@ApiProperty()
accessToken: string;
@ApiProperty()
refreshToken: string;
}
export class AuthResponseDto {
@ApiProperty({ type: UserResponseDto })
user: UserResponseDto;
@ApiProperty({ type: TokensResponseDto })
tokens: TokensResponseDto;
}

View File

@ -1,14 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
export class LoginDto {
@ApiProperty({ description: 'Email del usuario', example: 'user@example.com' })
@IsEmail()
email: string;
@ApiProperty({ description: 'Password del usuario' })
@IsString()
@MinLength(1)
@MaxLength(100)
password: string;
}

View File

@ -1,48 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEmail,
IsString,
MinLength,
MaxLength,
IsUUID,
IsOptional,
IsEnum,
} from 'class-validator';
import { UserRole } from '../../../common/decorators/roles.decorator';
export class RegisterDto {
@ApiProperty({ description: 'ID del tenant', format: 'uuid' })
@IsUUID()
tenant_id: string;
@ApiProperty({ description: 'Email del usuario', example: 'user@example.com' })
@IsEmail()
email: string;
@ApiProperty({ description: 'Password (min 8 caracteres)', minLength: 8 })
@IsString()
@MinLength(8)
@MaxLength(100)
password: string;
@ApiPropertyOptional({ description: 'Nombre', maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
first_name?: string;
@ApiPropertyOptional({ description: 'Apellido', maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
last_name?: string;
@ApiPropertyOptional({
description: 'Rol del usuario',
enum: UserRole,
default: UserRole.VIEWER,
})
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
}

View File

@ -1,46 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity('sessions', { schema: 'auth' })
@Index(['user_id'])
@Index(['expires_at'])
export class Session {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
user_id: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ length: 500 })
refresh_token_hash: string;
@Column({ length: 100, nullable: true })
device_info: string | null;
@Column({ length: 50, nullable: true })
ip_address: string | null;
@Column({ type: 'timestamptz' })
expires_at: Date;
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@Column({ type: 'timestamptz', nullable: true })
last_used_at: Date | null;
@Column({ default: true })
is_active: boolean;
}

View File

@ -1,83 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { UserRole } from '../../../common/decorators/roles.decorator';
import { Tenant } from '../../tenants/entities/tenant.entity';
export enum UserStatus {
PENDING = 'pending',
ACTIVE = 'active',
SUSPENDED = 'suspended',
}
@Entity('users', { schema: 'auth' })
@Index(['tenant_id', 'email'], { unique: true })
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
@Index()
tenant_id: string;
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@Column({ length: 255 })
email: string;
@Column({ length: 255 })
password_hash: string;
@Column({ length: 100, nullable: true })
first_name: string | null;
@Column({ length: 100, nullable: true })
last_name: string | null;
@Column({ length: 500, nullable: true })
avatar_url: string | null;
@Column({
type: 'enum',
enum: UserRole,
default: UserRole.VIEWER,
})
role: UserRole;
@Column({
type: 'enum',
enum: UserStatus,
default: UserStatus.PENDING,
})
status: UserStatus;
@Column({ type: 'timestamptz', nullable: true })
last_login_at: Date | null;
@Column({ type: 'jsonb', nullable: true })
preferences: Record<string, any> | null;
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updated_at: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deleted_at: Date | null;
// Virtual property
get fullName(): string {
return [this.first_name, this.last_name].filter(Boolean).join(' ') || this.email;
}
}

View File

@ -1,165 +0,0 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User, UserStatus } from '../entities/user.entity';
import { Session } from '../entities/session.entity';
import { RegisterDto } from '../dto/register.dto';
import { LoginDto } from '../dto/login.dto';
import { TenantsService } from '../../tenants/services/tenants.service';
import { UserRole } from '../../../common/decorators/roles.decorator';
export interface JwtPayload {
sub: string;
email: string;
tenantId: string;
role: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(Session)
private readonly sessionRepository: Repository<Session>,
private readonly jwtService: JwtService,
private readonly tenantsService: TenantsService,
) {}
async register(dto: RegisterDto): Promise<{ user: User; tokens: AuthTokens }> {
// Verificar si el email ya existe en el tenant
const existingUser = await this.userRepository.findOne({
where: { email: dto.email, tenant_id: dto.tenant_id },
});
if (existingUser) {
throw new ConflictException('Email already registered in this tenant');
}
// Hash password
const passwordHash = await bcrypt.hash(dto.password, 12);
// Crear usuario
const user = this.userRepository.create({
tenant_id: dto.tenant_id,
email: dto.email,
password_hash: passwordHash,
first_name: dto.first_name,
last_name: dto.last_name,
role: dto.role || UserRole.VIEWER,
status: UserStatus.ACTIVE,
});
await this.userRepository.save(user);
// Generar tokens
const tokens = await this.generateTokens(user);
return { user, tokens };
}
async login(dto: LoginDto): Promise<{ user: User; tokens: AuthTokens }> {
// Buscar usuario
const user = await this.userRepository.findOne({
where: { email: dto.email },
});
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Verificar password
const isPasswordValid = await bcrypt.compare(dto.password, user.password_hash);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
// Verificar estado
if (user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('Account is not active');
}
// Actualizar ultimo login
user.last_login_at = new Date();
await this.userRepository.save(user);
// Generar tokens
const tokens = await this.generateTokens(user);
return { user, tokens };
}
async logout(userId: string, refreshToken: string): Promise<void> {
const tokenHash = await bcrypt.hash(refreshToken, 10);
await this.sessionRepository.update(
{ user_id: userId, refresh_token_hash: tokenHash },
{ is_active: false },
);
}
async refreshTokens(refreshToken: string): Promise<AuthTokens> {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: process.env.JWT_REFRESH_SECRET,
});
const user = await this.userRepository.findOne({
where: { id: payload.sub },
});
if (!user || user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('Invalid refresh token');
}
return this.generateTokens(user);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}
async validateUser(payload: JwtPayload): Promise<User | null> {
return this.userRepository.findOne({
where: { id: payload.sub },
});
}
private async generateTokens(user: User): Promise<AuthTokens> {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
tenantId: user.tenant_id,
role: user.role,
};
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload),
this.jwtService.signAsync(payload, {
secret: process.env.JWT_REFRESH_SECRET,
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d',
}),
]);
// Guardar sesion
const refreshTokenHash = await bcrypt.hash(refreshToken, 10);
const session = this.sessionRepository.create({
user_id: user.id,
refresh_token_hash: refreshTokenHash,
expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
});
await this.sessionRepository.save(session);
return { accessToken, refreshToken };
}
}

View File

@ -1,35 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService, JwtPayload } from '../services/auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
});
}
async validate(payload: JwtPayload) {
const user = await this.authService.validateUser(payload);
if (!user) {
throw new UnauthorizedException('User not found');
}
// Retornar data que estara disponible en request.user
return {
id: user.id,
email: user.email,
tenantId: user.tenant_id,
role: user.role,
};
}
}

View File

@ -1,108 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { BrandService } from '../services/brand.service';
import { CreateBrandDto } from '../dto/create-brand.dto';
import { UpdateBrandDto } from '../dto/update-brand.dto';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { TenantMemberGuard } from '../../../common/guards/tenant-member.guard';
import { CurrentTenant } from '../../../common/decorators/current-tenant.decorator';
import { PaginationDto } from '../../../shared/dto/pagination.dto';
@ApiTags('CRM - Brands')
@Controller('crm/brands')
@UseGuards(JwtAuthGuard, TenantMemberGuard)
@ApiBearerAuth('JWT-auth')
export class BrandController {
constructor(private readonly brandService: BrandService) {}
@Get()
@ApiOperation({ summary: 'Listar marcas del tenant' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'clientId', required: false, type: String })
@ApiQuery({ name: 'sortBy', required: false, type: String })
@ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] })
async findAll(
@CurrentTenant() tenantId: string,
@Query() pagination: PaginationDto,
@Query('clientId') clientId?: string,
) {
return this.brandService.findAllPaginated(tenantId, pagination, clientId);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener marca por ID' })
@ApiResponse({ status: 200, description: 'Marca encontrada' })
@ApiResponse({ status: 404, description: 'Marca no encontrada' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.brandService.findOneWithClient(tenantId, id);
}
@Get('client/:clientId')
@ApiOperation({ summary: 'Listar marcas de un cliente' })
async findByClient(
@CurrentTenant() tenantId: string,
@Param('clientId', ParseUUIDPipe) clientId: string,
) {
return this.brandService.findByClientId(tenantId, clientId);
}
@Post()
@ApiOperation({ summary: 'Crear nueva marca' })
@ApiResponse({ status: 201, description: 'Marca creada' })
@ApiResponse({ status: 409, description: 'Slug ya existe' })
@ApiResponse({ status: 404, description: 'Cliente no encontrado' })
async create(
@CurrentTenant() tenantId: string,
@Body() dto: CreateBrandDto,
) {
return this.brandService.create(tenantId, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Actualizar marca' })
@ApiResponse({ status: 200, description: 'Marca actualizada' })
@ApiResponse({ status: 404, description: 'Marca no encontrada' })
async update(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateBrandDto,
) {
return this.brandService.update(tenantId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Eliminar marca (soft delete)' })
@ApiResponse({ status: 204, description: 'Marca eliminada' })
@ApiResponse({ status: 404, description: 'Marca no encontrada' })
async remove(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.brandService.remove(tenantId, id);
}
}

View File

@ -1,96 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { ClientService } from '../services/client.service';
import { CreateClientDto } from '../dto/create-client.dto';
import { UpdateClientDto } from '../dto/update-client.dto';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { TenantMemberGuard } from '../../../common/guards/tenant-member.guard';
import { CurrentTenant } from '../../../common/decorators/current-tenant.decorator';
import { PaginationDto } from '../../../shared/dto/pagination.dto';
@ApiTags('CRM - Clients')
@Controller('crm/clients')
@UseGuards(JwtAuthGuard, TenantMemberGuard)
@ApiBearerAuth('JWT-auth')
export class ClientController {
constructor(private readonly clientService: ClientService) {}
@Get()
@ApiOperation({ summary: 'Listar clientes del tenant' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'sortBy', required: false, type: String })
@ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] })
async findAll(
@CurrentTenant() tenantId: string,
@Query() pagination: PaginationDto,
) {
return this.clientService.findAllPaginated(tenantId, pagination);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener cliente por ID' })
@ApiResponse({ status: 200, description: 'Cliente encontrado' })
@ApiResponse({ status: 404, description: 'Cliente no encontrado' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.clientService.findOneOrFail(tenantId, id);
}
@Post()
@ApiOperation({ summary: 'Crear nuevo cliente' })
@ApiResponse({ status: 201, description: 'Cliente creado' })
@ApiResponse({ status: 409, description: 'Slug ya existe' })
async create(
@CurrentTenant() tenantId: string,
@Body() dto: CreateClientDto,
) {
return this.clientService.create(tenantId, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Actualizar cliente' })
@ApiResponse({ status: 200, description: 'Cliente actualizado' })
@ApiResponse({ status: 404, description: 'Cliente no encontrado' })
async update(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateClientDto,
) {
return this.clientService.update(tenantId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Eliminar cliente (soft delete)' })
@ApiResponse({ status: 204, description: 'Cliente eliminado' })
@ApiResponse({ status: 404, description: 'Cliente no encontrado' })
async remove(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.clientService.remove(tenantId, id);
}
}

View File

@ -1,108 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { ProductService } from '../services/product.service';
import { CreateProductDto } from '../dto/create-product.dto';
import { UpdateProductDto } from '../dto/update-product.dto';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { TenantMemberGuard } from '../../../common/guards/tenant-member.guard';
import { CurrentTenant } from '../../../common/decorators/current-tenant.decorator';
import { PaginationDto } from '../../../shared/dto/pagination.dto';
@ApiTags('CRM - Products')
@Controller('crm/products')
@UseGuards(JwtAuthGuard, TenantMemberGuard)
@ApiBearerAuth('JWT-auth')
export class ProductController {
constructor(private readonly productService: ProductService) {}
@Get()
@ApiOperation({ summary: 'Listar productos del tenant' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'brandId', required: false, type: String })
@ApiQuery({ name: 'sortBy', required: false, type: String })
@ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] })
async findAll(
@CurrentTenant() tenantId: string,
@Query() pagination: PaginationDto,
@Query('brandId') brandId?: string,
) {
return this.productService.findAllPaginated(tenantId, pagination, brandId);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener producto por ID' })
@ApiResponse({ status: 200, description: 'Producto encontrado' })
@ApiResponse({ status: 404, description: 'Producto no encontrado' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.productService.findOneWithBrand(tenantId, id);
}
@Get('brand/:brandId')
@ApiOperation({ summary: 'Listar productos de una marca' })
async findByBrand(
@CurrentTenant() tenantId: string,
@Param('brandId', ParseUUIDPipe) brandId: string,
) {
return this.productService.findByBrandId(tenantId, brandId);
}
@Post()
@ApiOperation({ summary: 'Crear nuevo producto' })
@ApiResponse({ status: 201, description: 'Producto creado' })
@ApiResponse({ status: 409, description: 'Slug ya existe' })
@ApiResponse({ status: 404, description: 'Marca no encontrada' })
async create(
@CurrentTenant() tenantId: string,
@Body() dto: CreateProductDto,
) {
return this.productService.create(tenantId, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Actualizar producto' })
@ApiResponse({ status: 200, description: 'Producto actualizado' })
@ApiResponse({ status: 404, description: 'Producto no encontrado' })
async update(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateProductDto,
) {
return this.productService.update(tenantId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Eliminar producto (soft delete)' })
@ApiResponse({ status: 204, description: 'Producto eliminado' })
@ApiResponse({ status: 404, description: 'Producto no encontrado' })
async remove(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.productService.remove(tenantId, id);
}
}

View File

@ -1,25 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
// Entities
import { Client } from './entities/client.entity';
import { Brand } from './entities/brand.entity';
import { Product } from './entities/product.entity';
// Services
import { ClientService } from './services/client.service';
import { BrandService } from './services/brand.service';
import { ProductService } from './services/product.service';
// Controllers
import { ClientController } from './controllers/client.controller';
import { BrandController } from './controllers/brand.controller';
import { ProductController } from './controllers/product.controller';
@Module({
imports: [TypeOrmModule.forFeature([Client, Brand, Product])],
controllers: [ClientController, BrandController, ProductController],
providers: [ClientService, BrandService, ProductService],
exports: [ClientService, BrandService, ProductService],
})
export class CrmModule {}

View File

@ -1,80 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsUUID,
IsUrl,
MaxLength,
Matches,
IsBoolean,
IsHexColor,
} from 'class-validator';
export class CreateBrandDto {
@ApiProperty({ description: 'ID del cliente', format: 'uuid' })
@IsUUID()
client_id: string;
@ApiProperty({ description: 'Nombre de la marca', maxLength: 255 })
@IsString()
@MaxLength(255)
name: string;
@ApiPropertyOptional({
description: 'Slug URL-friendly',
maxLength: 100,
})
@IsOptional()
@IsString()
@MaxLength(100)
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'Slug must be lowercase letters, numbers, and hyphens only',
})
slug?: string;
@ApiPropertyOptional({ description: 'Descripcion de la marca' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'URL del logo', maxLength: 500 })
@IsOptional()
@IsUrl()
@MaxLength(500)
logo_url?: string;
@ApiPropertyOptional({ description: 'Color primario (hex)', example: '#3B82F6' })
@IsOptional()
@IsHexColor()
primary_color?: string;
@ApiPropertyOptional({ description: 'Color secundario (hex)', example: '#10B981' })
@IsOptional()
@IsHexColor()
secondary_color?: string;
@ApiPropertyOptional({ description: 'Voz de la marca / tono de comunicacion' })
@IsOptional()
@IsString()
brand_voice?: string;
@ApiPropertyOptional({ description: 'Audiencia objetivo' })
@IsOptional()
@IsString()
target_audience?: string;
@ApiPropertyOptional({ description: 'URL del manual de marca', maxLength: 500 })
@IsOptional()
@IsUrl()
@MaxLength(500)
guidelines_url?: string;
@ApiPropertyOptional({ description: 'Marca activa', default: true })
@IsOptional()
@IsBoolean()
is_active?: boolean;
@ApiPropertyOptional({ description: 'Metadata adicional' })
@IsOptional()
metadata?: Record<string, any>;
}

View File

@ -1,86 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsEnum,
IsEmail,
IsUrl,
MaxLength,
Matches,
IsBoolean,
} from 'class-validator';
import { ClientType } from '../entities/client.entity';
export class CreateClientDto {
@ApiProperty({ description: 'Nombre del cliente', maxLength: 255 })
@IsString()
@MaxLength(255)
name: string;
@ApiPropertyOptional({
description: 'Slug URL-friendly (se genera automaticamente si no se provee)',
maxLength: 100,
})
@IsOptional()
@IsString()
@MaxLength(100)
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'Slug must be lowercase letters, numbers, and hyphens only',
})
slug?: string;
@ApiPropertyOptional({ enum: ClientType, default: ClientType.COMPANY })
@IsOptional()
@IsEnum(ClientType)
type?: ClientType;
@ApiPropertyOptional({ description: 'Industria', maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
industry?: string;
@ApiPropertyOptional({ description: 'Sitio web', maxLength: 500 })
@IsOptional()
@IsUrl()
@MaxLength(500)
website?: string;
@ApiPropertyOptional({ description: 'URL del logo', maxLength: 500 })
@IsOptional()
@IsUrl()
@MaxLength(500)
logo_url?: string;
@ApiPropertyOptional({ description: 'Nombre del contacto', maxLength: 200 })
@IsOptional()
@IsString()
@MaxLength(200)
contact_name?: string;
@ApiPropertyOptional({ description: 'Email del contacto' })
@IsOptional()
@IsEmail()
@MaxLength(255)
contact_email?: string;
@ApiPropertyOptional({ description: 'Telefono del contacto', maxLength: 50 })
@IsOptional()
@IsString()
@MaxLength(50)
contact_phone?: string;
@ApiPropertyOptional({ description: 'Notas adicionales' })
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({ description: 'Cliente activo', default: true })
@IsOptional()
@IsBoolean()
is_active?: boolean;
@ApiPropertyOptional({ description: 'Metadata adicional' })
@IsOptional()
metadata?: Record<string, any>;
}

View File

@ -1,87 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsUUID,
IsUrl,
MaxLength,
Matches,
IsBoolean,
IsNumber,
IsArray,
Min,
} from 'class-validator';
export class CreateProductDto {
@ApiProperty({ description: 'ID de la marca', format: 'uuid' })
@IsUUID()
brand_id: string;
@ApiProperty({ description: 'Nombre del producto', maxLength: 255 })
@IsString()
@MaxLength(255)
name: string;
@ApiPropertyOptional({
description: 'Slug URL-friendly',
maxLength: 100,
})
@IsOptional()
@IsString()
@MaxLength(100)
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'Slug must be lowercase letters, numbers, and hyphens only',
})
slug?: string;
@ApiPropertyOptional({ description: 'Descripcion del producto' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'SKU del producto', maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
sku?: string;
@ApiPropertyOptional({ description: 'Categoria', maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
category?: string;
@ApiPropertyOptional({ description: 'Precio del producto', minimum: 0 })
@IsOptional()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
price?: number;
@ApiPropertyOptional({ description: 'Moneda', default: 'USD', maxLength: 3 })
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
@ApiPropertyOptional({
description: 'URLs de imagenes del producto',
type: [String],
})
@IsOptional()
@IsArray()
@IsUrl({}, { each: true })
image_urls?: string[];
@ApiPropertyOptional({ description: 'Atributos personalizados' })
@IsOptional()
attributes?: Record<string, any>;
@ApiPropertyOptional({ description: 'Producto activo', default: true })
@IsOptional()
@IsBoolean()
is_active?: boolean;
@ApiPropertyOptional({ description: 'Metadata adicional' })
@IsOptional()
metadata?: Record<string, any>;
}

View File

@ -1,6 +0,0 @@
import { PartialType, OmitType } from '@nestjs/swagger';
import { CreateBrandDto } from './create-brand.dto';
export class UpdateBrandDto extends PartialType(
OmitType(CreateBrandDto, ['client_id'] as const),
) {}

View File

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

View File

@ -1,6 +0,0 @@
import { PartialType, OmitType } from '@nestjs/swagger';
import { CreateProductDto } from './create-product.dto';
export class UpdateProductDto extends PartialType(
OmitType(CreateProductDto, ['brand_id'] as const),
) {}

View File

@ -1,62 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { TenantAwareEntity } from '../../../shared/entities/tenant-aware.entity';
import { Client } from './client.entity';
import { Product } from './product.entity';
@Entity('brands', { schema: 'crm' })
@Index(['tenant_id', 'slug'], { unique: true })
export class Brand extends TenantAwareEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
client_id: string;
@ManyToOne(() => Client, (client) => client.brands, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'client_id' })
client: Client;
@Column({ length: 255 })
name: string;
@Column({ length: 100 })
slug: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ length: 500, nullable: true })
logo_url: string | null;
@Column({ length: 7, nullable: true })
primary_color: string | null;
@Column({ length: 7, nullable: true })
secondary_color: string | null;
@Column({ type: 'text', nullable: true })
brand_voice: string | null;
@Column({ type: 'text', nullable: true })
target_audience: string | null;
@Column({ length: 500, nullable: true })
guidelines_url: string | null;
@Column({ default: true })
is_active: boolean;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;
@OneToMany(() => Product, (product) => product.brand)
products: Product[];
}

View File

@ -1,64 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
OneToMany,
} from 'typeorm';
import { TenantAwareEntity } from '../../../shared/entities/tenant-aware.entity';
import { Brand } from './brand.entity';
export enum ClientType {
COMPANY = 'company',
INDIVIDUAL = 'individual',
}
@Entity('clients', { schema: 'crm' })
@Index(['tenant_id', 'slug'], { unique: true })
export class Client extends TenantAwareEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 255 })
name: string;
@Column({ length: 100 })
slug: string;
@Column({
type: 'enum',
enum: ClientType,
default: ClientType.COMPANY,
})
type: ClientType;
@Column({ length: 100, nullable: true })
industry: string | null;
@Column({ length: 500, nullable: true })
website: string | null;
@Column({ length: 500, nullable: true })
logo_url: string | null;
@Column({ length: 200, nullable: true })
contact_name: string | null;
@Column({ length: 255, nullable: true })
contact_email: string | null;
@Column({ length: 50, nullable: true })
contact_phone: string | null;
@Column({ type: 'text', nullable: true })
notes: string | null;
@Column({ default: true })
is_active: boolean;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;
@OneToMany(() => Brand, (brand) => brand.client)
brands: Brand[];
}

View File

@ -1,57 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { TenantAwareEntity } from '../../../shared/entities/tenant-aware.entity';
import { Brand } from './brand.entity';
@Entity('products', { schema: 'crm' })
@Index(['tenant_id', 'slug'], { unique: true })
export class Product extends TenantAwareEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
brand_id: string;
@ManyToOne(() => Brand, (brand) => brand.products, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'brand_id' })
brand: Brand;
@Column({ length: 255 })
name: string;
@Column({ length: 100 })
slug: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ length: 100, nullable: true })
sku: string | null;
@Column({ length: 100, nullable: true })
category: string | null;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
price: number | null;
@Column({ length: 3, default: 'USD' })
currency: string;
@Column({ type: 'jsonb', nullable: true })
image_urls: string[] | null;
@Column({ type: 'jsonb', nullable: true })
attributes: Record<string, any> | null;
@Column({ default: true })
is_active: boolean;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;
}

View File

@ -1,140 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Brand } from '../entities/brand.entity';
import { CreateBrandDto } from '../dto/create-brand.dto';
import { UpdateBrandDto } from '../dto/update-brand.dto';
import { TenantAwareService } from '../../../shared/services/tenant-aware.service';
import {
PaginationDto,
PaginatedResult,
createPaginatedResult,
} from '../../../shared/dto/pagination.dto';
import { ClientService } from './client.service';
@Injectable()
export class BrandService extends TenantAwareService<Brand> {
constructor(
@InjectRepository(Brand)
private readonly brandRepository: Repository<Brand>,
private readonly clientService: ClientService,
) {
super(brandRepository, 'Brand');
}
async findAllPaginated(
tenantId: string,
pagination: PaginationDto,
clientId?: string,
): Promise<PaginatedResult<Brand>> {
const { skip, take, sortBy, sortOrder, search } = pagination;
const queryBuilder = this.brandRepository
.createQueryBuilder('brand')
.leftJoinAndSelect('brand.client', 'client')
.where('brand.tenant_id = :tenantId', { tenantId })
.andWhere('brand.deleted_at IS NULL');
if (clientId) {
queryBuilder.andWhere('brand.client_id = :clientId', { clientId });
}
if (search) {
queryBuilder.andWhere('brand.name ILIKE :search', {
search: `%${search}%`,
});
}
queryBuilder
.orderBy(`brand.${sortBy || 'created_at'}`, sortOrder || 'DESC')
.skip(skip)
.take(take);
const [data, total] = await queryBuilder.getManyAndCount();
return createPaginatedResult(data, total, pagination);
}
async findByClientId(tenantId: string, clientId: string): Promise<Brand[]> {
return this.brandRepository.find({
where: { tenant_id: tenantId, client_id: clientId },
order: { name: 'ASC' },
});
}
async create(tenantId: string, dto: CreateBrandDto): Promise<Brand> {
// Verificar que el cliente existe y pertenece al tenant
await this.clientService.findOneOrFail(tenantId, dto.client_id);
const slug = dto.slug || this.generateSlug(dto.name);
// Verificar slug unico en tenant
const existing = await this.brandRepository.findOne({
where: { tenant_id: tenantId, slug },
});
if (existing) {
throw new ConflictException(`Brand with slug '${slug}' already exists`);
}
const brand = this.brandRepository.create({
...dto,
tenant_id: tenantId,
slug,
});
return this.brandRepository.save(brand);
}
async update(
tenantId: string,
id: string,
dto: UpdateBrandDto,
): Promise<Brand> {
const brand = await this.findOneOrFail(tenantId, id);
// Si cambia slug, verificar unicidad
if (dto.slug && dto.slug !== brand.slug) {
const existing = await this.brandRepository.findOne({
where: { tenant_id: tenantId, slug: dto.slug },
});
if (existing) {
throw new ConflictException(
`Brand with slug '${dto.slug}' already exists`,
);
}
}
Object.assign(brand, dto);
return this.brandRepository.save(brand);
}
async remove(tenantId: string, id: string): Promise<void> {
await this.findOneOrFail(tenantId, id);
await this.brandRepository.softDelete({ id, tenant_id: tenantId });
}
async findOneWithClient(tenantId: string, id: string): Promise<Brand> {
const brand = await this.brandRepository.findOne({
where: { id, tenant_id: tenantId },
relations: ['client'],
});
if (!brand) {
throw new NotFoundException('Brand not found');
}
return brand;
}
private generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
}

View File

@ -1,116 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, ILike } from 'typeorm';
import { Client } from '../entities/client.entity';
import { CreateClientDto } from '../dto/create-client.dto';
import { UpdateClientDto } from '../dto/update-client.dto';
import { TenantAwareService } from '../../../shared/services/tenant-aware.service';
import {
PaginationDto,
PaginatedResult,
createPaginatedResult,
} from '../../../shared/dto/pagination.dto';
@Injectable()
export class ClientService extends TenantAwareService<Client> {
constructor(
@InjectRepository(Client)
private readonly clientRepository: Repository<Client>,
) {
super(clientRepository, 'Client');
}
async findAllPaginated(
tenantId: string,
pagination: PaginationDto,
): Promise<PaginatedResult<Client>> {
const { skip, take, sortBy, sortOrder, search } = pagination;
const queryBuilder = this.clientRepository
.createQueryBuilder('client')
.where('client.tenant_id = :tenantId', { tenantId })
.andWhere('client.deleted_at IS NULL');
if (search) {
queryBuilder.andWhere(
'(client.name ILIKE :search OR client.contact_email ILIKE :search)',
{ search: `%${search}%` },
);
}
queryBuilder
.orderBy(`client.${sortBy || 'created_at'}`, sortOrder || 'DESC')
.skip(skip)
.take(take);
const [data, total] = await queryBuilder.getManyAndCount();
return createPaginatedResult(data, total, pagination);
}
async create(tenantId: string, dto: CreateClientDto): Promise<Client> {
const slug = dto.slug || this.generateSlug(dto.name);
// Verificar slug unico en tenant
const existing = await this.clientRepository.findOne({
where: { tenant_id: tenantId, slug },
});
if (existing) {
throw new ConflictException(`Client with slug '${slug}' already exists`);
}
const client = this.clientRepository.create({
...dto,
tenant_id: tenantId,
slug,
});
return this.clientRepository.save(client);
}
async update(
tenantId: string,
id: string,
dto: UpdateClientDto,
): Promise<Client> {
const client = await this.findOneOrFail(tenantId, id);
// Si cambia slug, verificar unicidad
if (dto.slug && dto.slug !== client.slug) {
const existing = await this.clientRepository.findOne({
where: { tenant_id: tenantId, slug: dto.slug },
});
if (existing) {
throw new ConflictException(
`Client with slug '${dto.slug}' already exists`,
);
}
}
Object.assign(client, dto);
return this.clientRepository.save(client);
}
async remove(tenantId: string, id: string): Promise<void> {
await this.findOneOrFail(tenantId, id);
await this.clientRepository.softDelete({ id, tenant_id: tenantId });
}
async findBySlug(tenantId: string, slug: string): Promise<Client | null> {
return this.clientRepository.findOne({
where: { tenant_id: tenantId, slug },
});
}
private generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
}

View File

@ -1,141 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from '../entities/product.entity';
import { CreateProductDto } from '../dto/create-product.dto';
import { UpdateProductDto } from '../dto/update-product.dto';
import { TenantAwareService } from '../../../shared/services/tenant-aware.service';
import {
PaginationDto,
PaginatedResult,
createPaginatedResult,
} from '../../../shared/dto/pagination.dto';
import { BrandService } from './brand.service';
@Injectable()
export class ProductService extends TenantAwareService<Product> {
constructor(
@InjectRepository(Product)
private readonly productRepository: Repository<Product>,
private readonly brandService: BrandService,
) {
super(productRepository, 'Product');
}
async findAllPaginated(
tenantId: string,
pagination: PaginationDto,
brandId?: string,
): Promise<PaginatedResult<Product>> {
const { skip, take, sortBy, sortOrder, search } = pagination;
const queryBuilder = this.productRepository
.createQueryBuilder('product')
.leftJoinAndSelect('product.brand', 'brand')
.where('product.tenant_id = :tenantId', { tenantId })
.andWhere('product.deleted_at IS NULL');
if (brandId) {
queryBuilder.andWhere('product.brand_id = :brandId', { brandId });
}
if (search) {
queryBuilder.andWhere(
'(product.name ILIKE :search OR product.sku ILIKE :search)',
{ search: `%${search}%` },
);
}
queryBuilder
.orderBy(`product.${sortBy || 'created_at'}`, sortOrder || 'DESC')
.skip(skip)
.take(take);
const [data, total] = await queryBuilder.getManyAndCount();
return createPaginatedResult(data, total, pagination);
}
async findByBrandId(tenantId: string, brandId: string): Promise<Product[]> {
return this.productRepository.find({
where: { tenant_id: tenantId, brand_id: brandId },
order: { name: 'ASC' },
});
}
async create(tenantId: string, dto: CreateProductDto): Promise<Product> {
// Verificar que la marca existe y pertenece al tenant
await this.brandService.findOneOrFail(tenantId, dto.brand_id);
const slug = dto.slug || this.generateSlug(dto.name);
// Verificar slug unico en tenant
const existing = await this.productRepository.findOne({
where: { tenant_id: tenantId, slug },
});
if (existing) {
throw new ConflictException(`Product with slug '${slug}' already exists`);
}
const product = this.productRepository.create({
...dto,
tenant_id: tenantId,
slug,
});
return this.productRepository.save(product);
}
async update(
tenantId: string,
id: string,
dto: UpdateProductDto,
): Promise<Product> {
const product = await this.findOneOrFail(tenantId, id);
// Si cambia slug, verificar unicidad
if (dto.slug && dto.slug !== product.slug) {
const existing = await this.productRepository.findOne({
where: { tenant_id: tenantId, slug: dto.slug },
});
if (existing) {
throw new ConflictException(
`Product with slug '${dto.slug}' already exists`,
);
}
}
Object.assign(product, dto);
return this.productRepository.save(product);
}
async remove(tenantId: string, id: string): Promise<void> {
await this.findOneOrFail(tenantId, id);
await this.productRepository.softDelete({ id, tenant_id: tenantId });
}
async findOneWithBrand(tenantId: string, id: string): Promise<Product> {
const product = await this.productRepository.findOne({
where: { id, tenant_id: tenantId },
relations: ['brand', 'brand.client'],
});
if (!product) {
throw new NotFoundException('Product not found');
}
return product;
}
private generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
}

View File

@ -1,175 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { ContentPieceService } from '../services/content-piece.service';
import { CreateContentPieceDto, UpdateContentPieceDto } from '../dto';
import { ContentType, ContentStatus } from '../entities/content-piece.entity';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { TenantMemberGuard } from '@/common/guards/tenant-member.guard';
import { CurrentTenant } from '@/common/decorators/current-tenant.decorator';
import { PaginationDto } from '@/shared/dto/pagination.dto';
@ApiTags('Content Pieces')
@Controller('content-pieces')
@UseGuards(JwtAuthGuard, TenantMemberGuard)
@ApiBearerAuth('JWT-auth')
export class ContentPieceController {
constructor(private readonly contentPieceService: ContentPieceService) {}
@Get()
@ApiOperation({ summary: 'Get all content pieces with pagination' })
@ApiQuery({ name: 'projectId', required: false })
@ApiQuery({ name: 'type', enum: ContentType, required: false })
@ApiQuery({ name: 'status', enum: ContentStatus, required: false })
@ApiQuery({ name: 'search', required: false })
@ApiResponse({ status: 200, description: 'List of content pieces' })
async findAll(
@CurrentTenant() tenantId: string,
@Query() pagination: PaginationDto,
@Query('projectId') projectId?: string,
@Query('type') type?: ContentType,
@Query('status') status?: ContentStatus,
@Query('search') search?: string,
) {
return this.contentPieceService.findAllPaginated(tenantId, {
...pagination,
projectId,
type,
status,
search,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get content piece by ID' })
@ApiResponse({ status: 200, description: 'Content piece found' })
@ApiResponse({ status: 404, description: 'Content piece not found' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.contentPieceService.findById(tenantId, id);
}
@Get('project/:projectId')
@ApiOperation({ summary: 'Get content pieces by project' })
@ApiResponse({ status: 200, description: 'List of project content pieces' })
async findByProject(
@CurrentTenant() tenantId: string,
@Param('projectId', ParseUUIDPipe) projectId: string,
) {
return this.contentPieceService.findByProject(tenantId, projectId);
}
@Post()
@ApiOperation({ summary: 'Create new content piece' })
@ApiResponse({ status: 201, description: 'Content piece created' })
@ApiResponse({ status: 400, description: 'Bad request' })
async create(
@CurrentTenant() tenantId: string,
@Body() dto: CreateContentPieceDto,
) {
return this.contentPieceService.create(tenantId, dto);
}
@Post(':id/duplicate')
@ApiOperation({ summary: 'Duplicate content piece' })
@ApiResponse({ status: 201, description: 'Content piece duplicated' })
@ApiResponse({ status: 404, description: 'Content piece not found' })
async duplicate(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.contentPieceService.duplicate(tenantId, id);
}
@Put(':id')
@ApiOperation({ summary: 'Update content piece' })
@ApiResponse({ status: 200, description: 'Content piece updated' })
@ApiResponse({ status: 404, description: 'Content piece not found' })
async update(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateContentPieceDto,
) {
return this.contentPieceService.update(tenantId, id, dto);
}
@Put(':id/status')
@ApiOperation({ summary: 'Update content piece status' })
@ApiResponse({ status: 200, description: 'Content piece status updated' })
@ApiResponse({ status: 404, description: 'Content piece not found' })
async updateStatus(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body('status') status: ContentStatus,
) {
return this.contentPieceService.updateStatus(tenantId, id, status);
}
@Put(':id/assign')
@ApiOperation({ summary: 'Assign content piece to user' })
@ApiResponse({ status: 200, description: 'Content piece assigned' })
@ApiResponse({ status: 404, description: 'Content piece not found' })
async assign(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body('userId') userId: string | null,
) {
return this.contentPieceService.assignTo(tenantId, id, userId);
}
@Put(':id/schedule')
@ApiOperation({ summary: 'Schedule content piece' })
@ApiResponse({ status: 200, description: 'Content piece scheduled' })
@ApiResponse({ status: 404, description: 'Content piece not found' })
async schedule(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body('scheduledAt') scheduledAt: string,
) {
return this.contentPieceService.schedule(tenantId, id, new Date(scheduledAt));
}
@Put(':id/publish')
@ApiOperation({ summary: 'Mark content piece as published' })
@ApiResponse({ status: 200, description: 'Content piece published' })
@ApiResponse({ status: 404, description: 'Content piece not found' })
async publish(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body('publishedUrl') publishedUrl?: string,
) {
return this.contentPieceService.publish(tenantId, id, publishedUrl);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete content piece (soft delete)' })
@ApiResponse({ status: 204, description: 'Content piece deleted' })
@ApiResponse({ status: 404, description: 'Content piece not found' })
async delete(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.contentPieceService.softDelete(tenantId, id);
}
}

View File

@ -1,2 +0,0 @@
export * from './project.controller';
export * from './content-piece.controller';

View File

@ -1,137 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { ProjectService } from '../services/project.service';
import { CreateProjectDto, UpdateProjectDto } from '../dto';
import { ProjectStatus } from '../entities/project.entity';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { TenantMemberGuard } from '@/common/guards/tenant-member.guard';
import { CurrentTenant } from '@/common/decorators/current-tenant.decorator';
import { PaginationDto } from '@/shared/dto/pagination.dto';
@ApiTags('Projects')
@Controller('projects')
@UseGuards(JwtAuthGuard, TenantMemberGuard)
@ApiBearerAuth('JWT-auth')
export class ProjectController {
constructor(private readonly projectService: ProjectService) {}
@Get()
@ApiOperation({ summary: 'Get all projects with pagination' })
@ApiQuery({ name: 'brandId', required: false })
@ApiQuery({ name: 'status', enum: ProjectStatus, required: false })
@ApiQuery({ name: 'search', required: false })
@ApiResponse({ status: 200, description: 'List of projects' })
async findAll(
@CurrentTenant() tenantId: string,
@Query() pagination: PaginationDto,
@Query('brandId') brandId?: string,
@Query('status') status?: ProjectStatus,
@Query('search') search?: string,
) {
return this.projectService.findAllPaginated(tenantId, {
...pagination,
brandId,
status,
search,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get project by ID' })
@ApiResponse({ status: 200, description: 'Project found' })
@ApiResponse({ status: 404, description: 'Project not found' })
async findOne(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.projectService.findById(tenantId, id);
}
@Get('brand/:brandId')
@ApiOperation({ summary: 'Get projects by brand' })
@ApiResponse({ status: 200, description: 'List of brand projects' })
async findByBrand(
@CurrentTenant() tenantId: string,
@Param('brandId', ParseUUIDPipe) brandId: string,
) {
return this.projectService.findByBrand(tenantId, brandId);
}
@Post()
@ApiOperation({ summary: 'Create new project' })
@ApiResponse({ status: 201, description: 'Project created' })
@ApiResponse({ status: 400, description: 'Bad request' })
async create(
@CurrentTenant() tenantId: string,
@Body() dto: CreateProjectDto,
) {
return this.projectService.create(tenantId, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Update project' })
@ApiResponse({ status: 200, description: 'Project updated' })
@ApiResponse({ status: 404, description: 'Project not found' })
async update(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateProjectDto,
) {
return this.projectService.update(tenantId, id, dto);
}
@Put(':id/status')
@ApiOperation({ summary: 'Update project status' })
@ApiResponse({ status: 200, description: 'Project status updated' })
@ApiResponse({ status: 404, description: 'Project not found' })
async updateStatus(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body('status') status: ProjectStatus,
) {
return this.projectService.updateStatus(tenantId, id, status);
}
@Put(':id/progress')
@ApiOperation({ summary: 'Update project progress' })
@ApiResponse({ status: 200, description: 'Project progress updated' })
@ApiResponse({ status: 404, description: 'Project not found' })
async updateProgress(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
@Body('progress') progress: number,
) {
return this.projectService.updateProgress(tenantId, id, progress);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete project (soft delete)' })
@ApiResponse({ status: 204, description: 'Project deleted' })
@ApiResponse({ status: 404, description: 'Project not found' })
async delete(
@CurrentTenant() tenantId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.projectService.softDelete(tenantId, id);
}
}

View File

@ -1,101 +0,0 @@
import {
IsString,
IsOptional,
IsEnum,
IsUUID,
IsArray,
IsObject,
IsInt,
Min,
IsDateString,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ContentType,
ContentStatus,
SocialPlatform,
} from '../entities/content-piece.entity';
export class CreateContentPieceDto {
@ApiProperty({ description: 'Content title' })
@IsString()
title: string;
@ApiProperty({ description: 'Project ID', format: 'uuid' })
@IsUUID()
project_id: string;
@ApiPropertyOptional({ description: 'Assigned user ID', format: 'uuid' })
@IsOptional()
@IsUUID()
assigned_to?: string;
@ApiPropertyOptional({ enum: ContentType, default: ContentType.SOCIAL_POST })
@IsOptional()
@IsEnum(ContentType)
type?: ContentType;
@ApiPropertyOptional({ enum: ContentStatus, default: ContentStatus.IDEA })
@IsOptional()
@IsEnum(ContentStatus)
status?: ContentStatus;
@ApiPropertyOptional({ description: 'Target platforms', type: [String] })
@IsOptional()
@IsArray()
@IsEnum(SocialPlatform, { each: true })
platforms?: SocialPlatform[];
@ApiPropertyOptional({ description: 'Content text' })
@IsOptional()
@IsString()
content?: string;
@ApiPropertyOptional({ description: 'Content HTML' })
@IsOptional()
@IsString()
content_html?: string;
@ApiPropertyOptional({ description: 'AI prompt used' })
@IsOptional()
@IsString()
prompt_used?: string;
@ApiPropertyOptional({ description: 'AI generation metadata' })
@IsOptional()
@IsObject()
ai_metadata?: Record<string, any>;
@ApiPropertyOptional({ description: 'Asset IDs', type: [String] })
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
asset_ids?: string[];
@ApiPropertyOptional({ description: 'Call to action' })
@IsOptional()
@IsString()
call_to_action?: string;
@ApiPropertyOptional({ description: 'Hashtags', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
hashtags?: string[];
@ApiPropertyOptional({ description: 'Scheduled publication date' })
@IsOptional()
@IsDateString()
scheduled_at?: string;
@ApiPropertyOptional({ description: 'Tags', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({ description: 'Additional metadata' })
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}

View File

@ -1,82 +0,0 @@
import {
IsString,
IsOptional,
IsEnum,
IsUUID,
IsArray,
IsObject,
IsInt,
Min,
Max,
IsDateString,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ProjectStatus, ProjectPriority } from '../entities/project.entity';
export class CreateProjectDto {
@ApiProperty({ description: 'Project name' })
@IsString()
name: string;
@ApiPropertyOptional({ description: 'URL-friendly slug' })
@IsOptional()
@IsString()
slug?: string;
@ApiProperty({ description: 'Brand ID', format: 'uuid' })
@IsUUID()
brand_id: string;
@ApiPropertyOptional({ description: 'Project owner ID', format: 'uuid' })
@IsOptional()
@IsUUID()
owner_id?: string;
@ApiPropertyOptional({ description: 'Project description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: 'Project brief/requirements' })
@IsOptional()
@IsString()
brief?: string;
@ApiPropertyOptional({ enum: ProjectStatus, default: ProjectStatus.DRAFT })
@IsOptional()
@IsEnum(ProjectStatus)
status?: ProjectStatus;
@ApiPropertyOptional({ enum: ProjectPriority, default: ProjectPriority.MEDIUM })
@IsOptional()
@IsEnum(ProjectPriority)
priority?: ProjectPriority;
@ApiPropertyOptional({ description: 'Project start date' })
@IsOptional()
@IsDateString()
start_date?: string;
@ApiPropertyOptional({ description: 'Project due date' })
@IsOptional()
@IsDateString()
due_date?: string;
@ApiPropertyOptional({ description: 'Tags', type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({ description: 'Project settings' })
@IsOptional()
@IsObject()
settings?: Record<string, any>;
@ApiPropertyOptional({ description: 'Progress (0-100)', default: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Max(100)
progress?: number;
}

View File

@ -1,4 +0,0 @@
export * from './create-project.dto';
export * from './update-project.dto';
export * from './create-content-piece.dto';
export * from './update-content-piece.dto';

View File

@ -1,6 +0,0 @@
import { PartialType, OmitType } from '@nestjs/swagger';
import { CreateContentPieceDto } from './create-content-piece.dto';
export class UpdateContentPieceDto extends PartialType(
OmitType(CreateContentPieceDto, ['project_id'] as const)
) {}

View File

@ -1,6 +0,0 @@
import { PartialType, OmitType } from '@nestjs/swagger';
import { CreateProjectDto } from './create-project.dto';
export class UpdateProjectDto extends PartialType(
OmitType(CreateProjectDto, ['brand_id'] as const)
) {}

View File

@ -1,127 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantAwareEntity } from '@/shared/entities/tenant-aware.entity';
import { Project } from './project.entity';
import { User } from '@/modules/auth/entities/user.entity';
export enum ContentType {
SOCIAL_POST = 'social_post',
BLOG_ARTICLE = 'blog_article',
EMAIL = 'email',
AD_COPY = 'ad_copy',
LANDING_PAGE = 'landing_page',
VIDEO_SCRIPT = 'video_script',
PRODUCT_DESCRIPTION = 'product_description',
OTHER = 'other',
}
export enum ContentStatus {
IDEA = 'idea',
DRAFTING = 'drafting',
REVIEW = 'review',
APPROVED = 'approved',
SCHEDULED = 'scheduled',
PUBLISHED = 'published',
ARCHIVED = 'archived',
}
export enum SocialPlatform {
FACEBOOK = 'facebook',
INSTAGRAM = 'instagram',
TWITTER = 'twitter',
LINKEDIN = 'linkedin',
TIKTOK = 'tiktok',
YOUTUBE = 'youtube',
PINTEREST = 'pinterest',
}
@Entity('content_pieces', { schema: 'projects' })
@Index(['tenant_id', 'project_id'])
@Index(['tenant_id', 'type'])
@Index(['tenant_id', 'status'])
export class ContentPiece extends TenantAwareEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
project_id: string;
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project: Project;
@Column('uuid', { nullable: true })
assigned_to: string | null;
@ManyToOne(() => User, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'assigned_to' })
assignee: User;
@Column({ length: 255 })
title: string;
@Column({ type: 'enum', enum: ContentType, default: ContentType.SOCIAL_POST })
type: ContentType;
@Column({ type: 'enum', enum: ContentStatus, default: ContentStatus.IDEA })
status: ContentStatus;
@Column('simple-array', { nullable: true })
platforms: SocialPlatform[] | null;
@Column({ type: 'text', nullable: true })
content: string | null;
@Column({ type: 'text', nullable: true })
content_html: string | null;
@Column({ type: 'text', nullable: true })
prompt_used: string | null;
@Column({ type: 'jsonb', nullable: true })
ai_metadata: Record<string, any> | null; // Model, tokens, etc.
@Column('simple-array', { nullable: true })
asset_ids: string[] | null;
@Column({ type: 'text', nullable: true })
call_to_action: string | null;
@Column('simple-array', { nullable: true })
hashtags: string[] | null;
@Column({ type: 'timestamptz', nullable: true })
scheduled_at: Date | null;
@Column({ type: 'timestamptz', nullable: true })
published_at: Date | null;
@Column({ type: 'text', nullable: true })
published_url: string | null;
@Column({ type: 'int', default: 0 })
version: number;
@Column('simple-array', { nullable: true })
tags: string[] | null;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updated_at: Date;
@Column({ type: 'timestamptz', nullable: true })
deleted_at: Date | null;
}

View File

@ -1,2 +0,0 @@
export * from './project.entity';
export * from './content-piece.entity';

View File

@ -1,97 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
ManyToOne,
JoinColumn,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { TenantAwareEntity } from '@/shared/entities/tenant-aware.entity';
import { Brand } from '@/modules/crm/entities/brand.entity';
import { User } from '@/modules/auth/entities/user.entity';
export enum ProjectStatus {
DRAFT = 'draft',
PLANNING = 'planning',
IN_PROGRESS = 'in_progress',
REVIEW = 'review',
COMPLETED = 'completed',
ARCHIVED = 'archived',
}
export enum ProjectPriority {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
URGENT = 'urgent',
}
@Entity('projects', { schema: 'projects' })
@Index(['tenant_id', 'brand_id'])
@Index(['tenant_id', 'status'])
export class Project extends TenantAwareEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
brand_id: string;
@ManyToOne(() => Brand, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'brand_id' })
brand: Brand;
@Column('uuid', { nullable: true })
owner_id: string | null;
@ManyToOne(() => User, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'owner_id' })
owner: User;
@Column({ length: 255 })
name: string;
@Column({ length: 255 })
slug: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'text', nullable: true })
brief: string | null;
@Column({ type: 'enum', enum: ProjectStatus, default: ProjectStatus.DRAFT })
status: ProjectStatus;
@Column({ type: 'enum', enum: ProjectPriority, default: ProjectPriority.MEDIUM })
priority: ProjectPriority;
@Column({ type: 'date', nullable: true })
start_date: Date | null;
@Column({ type: 'date', nullable: true })
due_date: Date | null;
@Column({ type: 'date', nullable: true })
completed_at: Date | null;
@Column('simple-array', { nullable: true })
tags: string[] | null;
@Column({ type: 'jsonb', nullable: true })
settings: Record<string, any> | null;
@Column({ type: 'int', default: 0 })
progress: number; // 0-100
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updated_at: Date;
@Column({ type: 'timestamptz', nullable: true })
deleted_at: Date | null;
}

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Project, ContentPiece } from './entities';
import { ProjectService, ContentPieceService } from './services';
import { ProjectController, ContentPieceController } from './controllers';
@Module({
imports: [TypeOrmModule.forFeature([Project, ContentPiece])],
controllers: [ProjectController, ContentPieceController],
providers: [ProjectService, ContentPieceService],
exports: [ProjectService, ContentPieceService],
})
export class ProjectsModule {}

View File

@ -1,188 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { ContentPiece, ContentStatus, ContentType } from '../entities/content-piece.entity';
import { CreateContentPieceDto, UpdateContentPieceDto } from '../dto';
import { TenantAwareService } from '@/shared/services/tenant-aware.service';
import { PaginationParams, PaginatedResult } from '@/shared/dto/pagination.dto';
@Injectable()
export class ContentPieceService extends TenantAwareService<ContentPiece> {
constructor(
@InjectRepository(ContentPiece)
private readonly contentPieceRepository: Repository<ContentPiece>,
) {
super(contentPieceRepository);
}
async findAllPaginated(
tenantId: string,
pagination: PaginationParams & {
projectId?: string;
type?: ContentType;
status?: ContentStatus;
search?: string;
},
): Promise<PaginatedResult<ContentPiece>> {
const {
page = 1,
limit = 20,
sortBy = 'created_at',
sortOrder = 'DESC',
projectId,
type,
status,
search,
} = pagination;
const queryBuilder = this.contentPieceRepository
.createQueryBuilder('content')
.leftJoinAndSelect('content.project', 'project')
.leftJoinAndSelect('content.assignee', 'assignee')
.where('content.tenant_id = :tenantId', { tenantId })
.andWhere('content.deleted_at IS NULL');
if (projectId) {
queryBuilder.andWhere('content.project_id = :projectId', { projectId });
}
if (type) {
queryBuilder.andWhere('content.type = :type', { type });
}
if (status) {
queryBuilder.andWhere('content.status = :status', { status });
}
if (search) {
queryBuilder.andWhere(
'(content.title ILIKE :search OR content.content ILIKE :search)',
{ search: `%${search}%` },
);
}
queryBuilder.orderBy(`content.${sortBy}`, sortOrder);
const total = await queryBuilder.getCount();
const totalPages = Math.ceil(total / limit);
queryBuilder.skip((page - 1) * limit).take(limit);
const data = await queryBuilder.getMany();
return {
data,
meta: {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
async findById(tenantId: string, id: string): Promise<ContentPiece> {
const contentPiece = await this.contentPieceRepository.findOne({
where: { id, tenant_id: tenantId, deleted_at: IsNull() },
relations: ['project', 'assignee'],
});
if (!contentPiece) {
throw new NotFoundException(`Content piece with ID ${id} not found`);
}
return contentPiece;
}
async findByProject(tenantId: string, projectId: string): Promise<ContentPiece[]> {
return this.contentPieceRepository.find({
where: { tenant_id: tenantId, project_id: projectId, deleted_at: IsNull() },
relations: ['assignee'],
order: { created_at: 'DESC' },
});
}
async create(tenantId: string, dto: CreateContentPieceDto): Promise<ContentPiece> {
const contentPiece = this.contentPieceRepository.create({
...dto,
tenant_id: tenantId,
version: 1,
});
return this.contentPieceRepository.save(contentPiece);
}
async update(tenantId: string, id: string, dto: UpdateContentPieceDto): Promise<ContentPiece> {
const contentPiece = await this.findById(tenantId, id);
// Increment version on content changes
if (dto.content && dto.content !== contentPiece.content) {
contentPiece.version += 1;
}
Object.assign(contentPiece, dto);
return this.contentPieceRepository.save(contentPiece);
}
async updateStatus(tenantId: string, id: string, status: ContentStatus): Promise<ContentPiece> {
const contentPiece = await this.findById(tenantId, id);
contentPiece.status = status;
if (status === ContentStatus.PUBLISHED) {
contentPiece.published_at = new Date();
}
return this.contentPieceRepository.save(contentPiece);
}
async assignTo(tenantId: string, id: string, userId: string | null): Promise<ContentPiece> {
const contentPiece = await this.findById(tenantId, id);
contentPiece.assigned_to = userId;
return this.contentPieceRepository.save(contentPiece);
}
async schedule(tenantId: string, id: string, scheduledAt: Date): Promise<ContentPiece> {
const contentPiece = await this.findById(tenantId, id);
contentPiece.scheduled_at = scheduledAt;
contentPiece.status = ContentStatus.SCHEDULED;
return this.contentPieceRepository.save(contentPiece);
}
async publish(tenantId: string, id: string, publishedUrl?: string): Promise<ContentPiece> {
const contentPiece = await this.findById(tenantId, id);
contentPiece.status = ContentStatus.PUBLISHED;
contentPiece.published_at = new Date();
if (publishedUrl) {
contentPiece.published_url = publishedUrl;
}
return this.contentPieceRepository.save(contentPiece);
}
async softDelete(tenantId: string, id: string): Promise<void> {
const contentPiece = await this.findById(tenantId, id);
contentPiece.deleted_at = new Date();
await this.contentPieceRepository.save(contentPiece);
}
async duplicate(tenantId: string, id: string): Promise<ContentPiece> {
const original = await this.findById(tenantId, id);
const duplicate = this.contentPieceRepository.create({
...original,
id: undefined,
title: `${original.title} (Copy)`,
status: ContentStatus.IDEA,
published_at: null,
published_url: null,
scheduled_at: null,
version: 1,
created_at: undefined,
updated_at: undefined,
});
return this.contentPieceRepository.save(duplicate);
}
}

View File

@ -1,2 +0,0 @@
export * from './project.service';
export * from './content-piece.service';

View File

@ -1,145 +0,0 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { Project, ProjectStatus } from '../entities/project.entity';
import { CreateProjectDto, UpdateProjectDto } from '../dto';
import { TenantAwareService } from '@/shared/services/tenant-aware.service';
import { PaginationParams, PaginatedResult } from '@/shared/dto/pagination.dto';
@Injectable()
export class ProjectService extends TenantAwareService<Project> {
constructor(
@InjectRepository(Project)
private readonly projectRepository: Repository<Project>,
) {
super(projectRepository);
}
async findAllPaginated(
tenantId: string,
pagination: PaginationParams & { brandId?: string; status?: ProjectStatus; search?: string },
): Promise<PaginatedResult<Project>> {
const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC', brandId, status, search } = pagination;
const queryBuilder = this.projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect('project.brand', 'brand')
.leftJoinAndSelect('project.owner', 'owner')
.where('project.tenant_id = :tenantId', { tenantId })
.andWhere('project.deleted_at IS NULL');
if (brandId) {
queryBuilder.andWhere('project.brand_id = :brandId', { brandId });
}
if (status) {
queryBuilder.andWhere('project.status = :status', { status });
}
if (search) {
queryBuilder.andWhere(
'(project.name ILIKE :search OR project.description ILIKE :search)',
{ search: `%${search}%` },
);
}
queryBuilder.orderBy(`project.${sortBy}`, sortOrder);
const total = await queryBuilder.getCount();
const totalPages = Math.ceil(total / limit);
queryBuilder.skip((page - 1) * limit).take(limit);
const data = await queryBuilder.getMany();
return {
data,
meta: {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
async findById(tenantId: string, id: string): Promise<Project> {
const project = await this.projectRepository.findOne({
where: { id, tenant_id: tenantId, deleted_at: IsNull() },
relations: ['brand', 'owner'],
});
if (!project) {
throw new NotFoundException(`Project with ID ${id} not found`);
}
return project;
}
async findByBrand(tenantId: string, brandId: string): Promise<Project[]> {
return this.projectRepository.find({
where: { tenant_id: tenantId, brand_id: brandId, deleted_at: IsNull() },
relations: ['owner'],
order: { created_at: 'DESC' },
});
}
async create(tenantId: string, dto: CreateProjectDto): Promise<Project> {
const slug = dto.slug || this.generateSlug(dto.name);
const project = this.projectRepository.create({
...dto,
slug,
tenant_id: tenantId,
});
return this.projectRepository.save(project);
}
async update(tenantId: string, id: string, dto: UpdateProjectDto): Promise<Project> {
const project = await this.findById(tenantId, id);
if (dto.slug && dto.slug !== project.slug) {
// Note: slug uniqueness check could be added here if needed
}
Object.assign(project, dto);
return this.projectRepository.save(project);
}
async updateStatus(tenantId: string, id: string, status: ProjectStatus): Promise<Project> {
const project = await this.findById(tenantId, id);
project.status = status;
if (status === ProjectStatus.COMPLETED) {
project.completed_at = new Date();
project.progress = 100;
}
return this.projectRepository.save(project);
}
async updateProgress(tenantId: string, id: string, progress: number): Promise<Project> {
const project = await this.findById(tenantId, id);
project.progress = Math.min(100, Math.max(0, progress));
return this.projectRepository.save(project);
}
async softDelete(tenantId: string, id: string): Promise<void> {
const project = await this.findById(tenantId, id);
project.deleted_at = new Date();
await this.projectRepository.save(project);
}
private generateSlug(name: string): string {
return name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
}

View File

@ -1,93 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Body,
Param,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { TenantsService } from '../services/tenants.service';
import { CreateTenantDto } from '../dto/create-tenant.dto';
import { UpdateTenantDto } from '../dto/update-tenant.dto';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { Roles, UserRole } from '../../../common/decorators/roles.decorator';
import { CurrentTenant } from '../../../common/decorators/current-tenant.decorator';
import { Public } from '../../../common/decorators/public.decorator';
@ApiTags('Tenants')
@Controller('tenants')
export class TenantsController {
constructor(private readonly tenantsService: TenantsService) {}
@Public()
@Post()
@ApiOperation({ summary: 'Crear nuevo tenant (signup)' })
@ApiResponse({ status: 201, description: 'Tenant creado' })
@ApiResponse({ status: 409, description: 'Slug ya existe' })
async create(@Body() dto: CreateTenantDto) {
return this.tenantsService.create(dto);
}
@Get('current')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Obtener tenant actual del usuario' })
async getCurrentTenant(@CurrentTenant() tenantId: string) {
return this.tenantsService.findByIdOrFail(tenantId);
}
@Get('current/usage')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Obtener uso del tenant actual' })
async getCurrentUsage(@CurrentTenant() tenantId: string) {
const tenant = await this.tenantsService.findByIdOrFail(tenantId);
const usage = await this.tenantsService.getUsage(tenantId);
return {
tenant: {
id: tenant.id,
name: tenant.name,
plan: tenant.plan,
},
usage,
};
}
@Put('current')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.OWNER, UserRole.ADMIN)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Actualizar tenant actual' })
async updateCurrent(
@CurrentTenant() tenantId: string,
@Body() dto: UpdateTenantDto,
) {
return this.tenantsService.update(tenantId, dto);
}
@Public()
@Get('plans')
@ApiOperation({ summary: 'Listar planes disponibles' })
async getPlans() {
return this.tenantsService.findAllPlans();
}
@Get(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.OWNER, UserRole.ADMIN)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Obtener tenant por ID' })
async findById(@Param('id', ParseUUIDPipe) id: string) {
return this.tenantsService.findByIdOrFail(id);
}
}

View File

@ -1,38 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsUUID,
IsOptional,
MaxLength,
Matches,
} from 'class-validator';
export class CreateTenantDto {
@ApiProperty({ description: 'Nombre de la organizacion', maxLength: 255 })
@IsString()
@MaxLength(255)
name: string;
@ApiProperty({
description: 'Slug URL-friendly (solo letras, numeros y guiones)',
maxLength: 100,
example: 'mi-empresa',
})
@IsString()
@MaxLength(100)
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'Slug must be lowercase letters, numbers, and hyphens only',
})
slug: string;
@ApiPropertyOptional({ description: 'ID del plan', format: 'uuid' })
@IsOptional()
@IsUUID()
plan_id?: string;
@ApiPropertyOptional({ description: 'URL del logo', maxLength: 500 })
@IsOptional()
@IsString()
@MaxLength(500)
logo_url?: string;
}

View File

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

View File

@ -1,65 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('tenant_plans', { schema: 'auth' })
export class TenantPlan {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 100, unique: true })
name: string;
@Column({ length: 50, unique: true })
code: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
price_monthly: number;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
price_yearly: number;
// Limites del plan
@Column({ type: 'int', default: 5 })
max_users: number;
@Column({ type: 'int', default: 10 })
max_clients: number;
@Column({ type: 'int', default: 20 })
max_brands: number;
@Column({ type: 'int', default: 100 })
max_generations_month: number;
@Column({ type: 'bigint', default: 5368709120 }) // 5GB default
max_storage_bytes: number;
@Column({ type: 'int', default: 0 })
max_custom_models: number;
@Column({ type: 'int', default: 0 })
max_training_month: number;
@Column({ type: 'jsonb', nullable: true })
features: Record<string, boolean> | null;
@Column({ default: true })
is_active: boolean;
@Column({ type: 'int', default: 0 })
sort_order: number;
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updated_at: Date;
}

View File

@ -1,67 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { TenantPlan } from './tenant-plan.entity';
export enum TenantStatus {
TRIAL = 'trial',
ACTIVE = 'active',
SUSPENDED = 'suspended',
CANCELLED = 'cancelled',
}
@Entity('tenants', { schema: 'auth' })
export class Tenant {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 255 })
name: string;
@Column({ length: 100, unique: true })
@Index()
slug: string;
@Column('uuid')
plan_id: string;
@ManyToOne(() => TenantPlan)
@JoinColumn({ name: 'plan_id' })
plan: TenantPlan;
@Column({
type: 'enum',
enum: TenantStatus,
default: TenantStatus.TRIAL,
})
status: TenantStatus;
@Column({ length: 500, nullable: true })
logo_url: string | null;
@Column({ type: 'jsonb', nullable: true })
settings: Record<string, any> | null;
@Column({ type: 'date', nullable: true })
trial_ends_at: Date | null;
@Column({ type: 'date', nullable: true })
billing_cycle_start: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updated_at: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deleted_at: Date | null;
}

View File

@ -1,119 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant, TenantStatus } from '../entities/tenant.entity';
import { TenantPlan } from '../entities/tenant-plan.entity';
import { CreateTenantDto } from '../dto/create-tenant.dto';
import { UpdateTenantDto } from '../dto/update-tenant.dto';
@Injectable()
export class TenantsService {
constructor(
@InjectRepository(Tenant)
private readonly tenantRepository: Repository<Tenant>,
@InjectRepository(TenantPlan)
private readonly planRepository: Repository<TenantPlan>,
) {}
async create(dto: CreateTenantDto): Promise<Tenant> {
// Verificar slug unico
const existingSlug = await this.tenantRepository.findOne({
where: { slug: dto.slug },
});
if (existingSlug) {
throw new ConflictException('Slug already exists');
}
// Obtener plan por defecto si no se especifica
let planId = dto.plan_id;
if (!planId) {
const freePlan = await this.planRepository.findOne({
where: { code: 'starter' },
});
if (freePlan) {
planId = freePlan.id;
}
}
// Calcular fin de trial (14 dias)
const trialEndsAt = new Date();
trialEndsAt.setDate(trialEndsAt.getDate() + 14);
const tenant = this.tenantRepository.create({
...dto,
plan_id: planId,
status: TenantStatus.TRIAL,
trial_ends_at: trialEndsAt,
});
return this.tenantRepository.save(tenant);
}
async findById(id: string): Promise<Tenant | null> {
return this.tenantRepository.findOne({
where: { id },
relations: ['plan'],
});
}
async findBySlug(slug: string): Promise<Tenant | null> {
return this.tenantRepository.findOne({
where: { slug },
relations: ['plan'],
});
}
async findByIdOrFail(id: string): Promise<Tenant> {
const tenant = await this.findById(id);
if (!tenant) {
throw new NotFoundException('Tenant not found');
}
return tenant;
}
async update(id: string, dto: UpdateTenantDto): Promise<Tenant> {
const tenant = await this.findByIdOrFail(id);
// Si cambia slug, verificar que no exista
if (dto.slug && dto.slug !== tenant.slug) {
const existingSlug = await this.tenantRepository.findOne({
where: { slug: dto.slug },
});
if (existingSlug) {
throw new ConflictException('Slug already exists');
}
}
Object.assign(tenant, dto);
return this.tenantRepository.save(tenant);
}
async remove(id: string): Promise<void> {
const tenant = await this.findByIdOrFail(id);
await this.tenantRepository.softDelete(id);
}
async getUsage(tenantId: string): Promise<any> {
// TODO: Implementar cuando esten los modulos de CRM, Assets, Generation
return {
users: 0,
clients: 0,
brands: 0,
generations_this_month: 0,
storage_used_bytes: 0,
};
}
async findAllPlans(): Promise<TenantPlan[]> {
return this.planRepository.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
}

View File

@ -1,15 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from './entities/tenant.entity';
import { TenantPlan } from './entities/tenant-plan.entity';
import { TenantsService } from './services/tenants.service';
import { TenantsController } from './controllers/tenants.controller';
@Module({
imports: [TypeOrmModule.forFeature([Tenant, TenantPlan])],
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
})
export class TenantsModule {}

View File

@ -1,77 +0,0 @@
/**
* Database Constants - Single Source of Truth
*
* @description Centraliza todos los nombres de schemas y tablas del sistema.
* Las entities deben usar estas constantes en lugar de strings hardcodeados.
*
* @usage
* ```typescript
* import { DB_SCHEMAS, DB_TABLES } from '@shared/constants';
*
* @Entity({ schema: DB_SCHEMAS.AUTH, name: DB_TABLES.AUTH.USERS })
* export class User { ... }
* ```
*
* @migration Para migrar entities existentes:
* - Buscar: `{ schema: 'auth'` Reemplazar: `{ schema: DB_SCHEMAS.AUTH`
* - Buscar: `'users'` en @Entity Reemplazar: `DB_TABLES.AUTH.USERS`
*/
/**
* Schema names del sistema
*/
export const DB_SCHEMAS = {
/** Schema de autenticación y usuarios */
AUTH: 'auth',
/** Schema de CRM (clientes, marcas, productos) */
CRM: 'crm',
/** Schema de assets/medios digitales */
ASSETS: 'assets',
/** Schema de proyectos y contenido */
PROJECTS: 'projects',
/** Schema de tenants/multi-tenancy */
TENANTS: 'tenants',
} as const;
/**
* Table names por schema
*/
export const DB_TABLES = {
AUTH: {
USERS: 'users',
SESSIONS: 'sessions',
PASSWORD_RESET_TOKENS: 'password_reset_tokens',
},
CRM: {
CLIENTS: 'clients',
BRANDS: 'brands',
PRODUCTS: 'products',
},
ASSETS: {
ASSETS: 'assets',
ASSET_FOLDERS: 'asset_folders',
ASSET_VERSIONS: 'asset_versions',
ASSET_TAGS: 'asset_tags',
},
PROJECTS: {
PROJECTS: 'projects',
CONTENT_PIECES: 'content_pieces',
CONTENT_VERSIONS: 'content_versions',
PROJECT_MEMBERS: 'project_members',
},
TENANTS: {
TENANTS: 'tenants',
TENANT_PLANS: 'tenant_plans',
TENANT_SETTINGS: 'tenant_settings',
},
} as const;
/**
* Type helpers para intellisense
*/
export type SchemaName = typeof DB_SCHEMAS[keyof typeof DB_SCHEMAS];
export type AuthTableName = typeof DB_TABLES.AUTH[keyof typeof DB_TABLES.AUTH];
export type CrmTableName = typeof DB_TABLES.CRM[keyof typeof DB_TABLES.CRM];
export type AssetsTableName = typeof DB_TABLES.ASSETS[keyof typeof DB_TABLES.ASSETS];
export type ProjectsTableName = typeof DB_TABLES.PROJECTS[keyof typeof DB_TABLES.PROJECTS];
export type TenantsTableName = typeof DB_TABLES.TENANTS[keyof typeof DB_TABLES.TENANTS];

View File

@ -1,109 +0,0 @@
/**
* Enums Constants - Single Source of Truth
*
* @description Centraliza todos los enums del sistema para evitar duplicación
* y asegurar consistencia entre backend, frontend y base de datos.
*
* @usage
* ```typescript
* import { UserStatusEnum, UserRoleEnum } from '@shared/constants';
* ```
*/
/**
* Estados de usuario
*/
export enum UserStatusEnum {
PENDING = 'pending',
ACTIVE = 'active',
SUSPENDED = 'suspended',
DELETED = 'deleted',
}
/**
* Roles de usuario
* @note Debe coincidir con la definición en roles.decorator.ts
*/
export enum UserRoleEnum {
SUPER_ADMIN = 'super_admin',
ADMIN = 'admin',
MANAGER = 'manager',
EDITOR = 'editor',
VIEWER = 'viewer',
}
/**
* Tipos de cliente
*/
export enum ClientTypeEnum {
COMPANY = 'company',
INDIVIDUAL = 'individual',
AGENCY = 'agency',
}
/**
* Estados de proyecto
*/
export enum ProjectStatusEnum {
DRAFT = 'draft',
ACTIVE = 'active',
ON_HOLD = 'on_hold',
COMPLETED = 'completed',
ARCHIVED = 'archived',
}
/**
* Tipos de contenido
*/
export enum ContentTypeEnum {
IMAGE = 'image',
VIDEO = 'video',
DOCUMENT = 'document',
POST = 'post',
STORY = 'story',
REEL = 'reel',
}
/**
* Estados de contenido
*/
export enum ContentStatusEnum {
DRAFT = 'draft',
REVIEW = 'review',
APPROVED = 'approved',
SCHEDULED = 'scheduled',
PUBLISHED = 'published',
ARCHIVED = 'archived',
}
/**
* Tipos de asset
*/
export enum AssetTypeEnum {
IMAGE = 'image',
VIDEO = 'video',
AUDIO = 'audio',
DOCUMENT = 'document',
ARCHIVE = 'archive',
OTHER = 'other',
}
/**
* Planes de tenant
*/
export enum TenantPlanEnum {
FREE = 'free',
STARTER = 'starter',
PROFESSIONAL = 'professional',
ENTERPRISE = 'enterprise',
}
/**
* Estados de tenant
*/
export enum TenantStatusEnum {
TRIAL = 'trial',
ACTIVE = 'active',
SUSPENDED = 'suspended',
CANCELLED = 'cancelled',
}

View File

@ -1,36 +0,0 @@
/**
* Constants Module - Single Source of Truth
*
* @description Exporta todas las constantes centralizadas del sistema.
* Usar este módulo en lugar de definir constantes locales.
*
* @usage
* ```typescript
* import { DB_SCHEMAS, DB_TABLES, UserStatusEnum } from '@shared/constants';
* ```
*/
// Database constants
export {
DB_SCHEMAS,
DB_TABLES,
type SchemaName,
type AuthTableName,
type CrmTableName,
type AssetsTableName,
type ProjectsTableName,
type TenantsTableName,
} from './database.constants';
// Enums constants
export {
UserStatusEnum,
UserRoleEnum,
ClientTypeEnum,
ProjectStatusEnum,
ContentTypeEnum,
ContentStatusEnum,
AssetTypeEnum,
TenantPlanEnum,
TenantStatusEnum,
} from './enums.constants';

View File

@ -1,102 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsInt, Min, Max, IsString, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export interface PaginationParams {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
search?: string;
}
export class PaginationDto implements PaginationParams {
@ApiPropertyOptional({
description: 'Pagina a obtener (1-indexed)',
minimum: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
description: 'Cantidad de items por pagina',
minimum: 1,
maximum: 100,
default: 20,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@ApiPropertyOptional({
description: 'Campo para ordenar',
})
@IsOptional()
@IsString()
sortBy?: string = 'created_at';
@ApiPropertyOptional({
description: 'Direccion del ordenamiento',
enum: ['ASC', 'DESC'],
default: 'DESC',
})
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC' = 'DESC';
@ApiPropertyOptional({
description: 'Termino de busqueda',
})
@IsOptional()
@IsString()
search?: string;
get skip(): number {
return ((this.page || 1) - 1) * (this.limit || 20);
}
get take(): number {
return this.limit || 20;
}
}
export interface PaginatedResult<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
export function createPaginatedResult<T>(
data: T[],
total: number,
pagination: PaginationDto,
): PaginatedResult<T> {
const page = pagination.page || 1;
const limit = pagination.limit || 20;
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}

View File

@ -1,36 +0,0 @@
import {
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
} from 'typeorm';
/**
* Base entity para todas las entidades multi-tenant.
* Todas las entidades de negocio DEBEN extender esta clase.
*
* @example
* @Entity('clients', { schema: 'crm' })
* export class Client extends TenantAwareEntity {
* @PrimaryGeneratedColumn('uuid')
* id: string;
*
* @Column({ length: 255 })
* name: string;
* }
*/
export abstract class TenantAwareEntity {
@Column('uuid')
@Index()
tenant_id: string;
@CreateDateColumn({ type: 'timestamptz' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updated_at: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deleted_at: Date | null;
}

View File

@ -1,272 +0,0 @@
/**
* Repository Interface - Generic repository contract
*
* @module platform-marketing-content/shared/repositories
*/
/**
* Service context with tenant and user info
*/
export interface ServiceContext {
tenantId: string;
userId: string;
}
/**
* Query options for repository methods
*/
export interface QueryOptions {
includeDeleted?: boolean;
relations?: string[];
}
/**
* Pagination request options
*/
export interface PaginationOptions {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
/**
* Pagination metadata
*/
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
/**
* Paginated response wrapper
*/
export interface PaginatedResult<T> {
data: T[];
meta: PaginationMeta;
}
/**
* Generic repository interface for data access
*
* This interface defines the contract for repository implementations,
* supporting TypeORM-based data access patterns.
*
* @template T - Entity type
*
* @example
* ```typescript
* export class ProjectRepository implements IBaseRepository<Project> {
* async findById(ctx: ServiceContext, id: string): Promise<Project | null> {
* // Implementation
* }
* }
* ```
*/
export interface IBaseRepository<T> {
/**
* Find entity by ID
*/
findById(
ctx: ServiceContext,
id: string,
options?: QueryOptions,
): Promise<T | null>;
/**
* Find one entity by criteria
*/
findOne(
ctx: ServiceContext,
criteria: Partial<T>,
options?: QueryOptions,
): Promise<T | null>;
/**
* Find all entities with pagination
*/
findAll(
ctx: ServiceContext,
filters?: PaginationOptions & Partial<T>,
options?: QueryOptions,
): Promise<PaginatedResult<T>>;
/**
* Find multiple entities by criteria
*/
findMany(
ctx: ServiceContext,
criteria: Partial<T>,
options?: QueryOptions,
): Promise<T[]>;
/**
* Create new entity
*/
create(ctx: ServiceContext, data: Partial<T>): Promise<T>;
/**
* Create multiple entities
*/
createMany(ctx: ServiceContext, data: Partial<T>[]): Promise<T[]>;
/**
* Update existing entity
*/
update(ctx: ServiceContext, id: string, data: Partial<T>): Promise<T | null>;
/**
* Update multiple entities by criteria
*/
updateMany(
ctx: ServiceContext,
criteria: Partial<T>,
data: Partial<T>,
): Promise<number>;
/**
* Soft delete entity
*/
softDelete(ctx: ServiceContext, id: string): Promise<boolean>;
/**
* Hard delete entity
*/
hardDelete(ctx: ServiceContext, id: string): Promise<boolean>;
/**
* Delete multiple entities by criteria
*/
deleteMany(ctx: ServiceContext, criteria: Partial<T>): Promise<number>;
/**
* Count entities matching criteria
*/
count(
ctx: ServiceContext,
criteria?: Partial<T>,
options?: QueryOptions,
): Promise<number>;
/**
* Check if entity exists
*/
exists(
ctx: ServiceContext,
id: string,
options?: QueryOptions,
): Promise<boolean>;
/**
* Execute raw SQL query
*/
query<R = unknown>(
ctx: ServiceContext,
sql: string,
params: unknown[],
): Promise<R[]>;
/**
* Execute raw SQL query and return first result
*/
queryOne<R = unknown>(
ctx: ServiceContext,
sql: string,
params: unknown[],
): Promise<R | null>;
}
/**
* Read-only repository interface
*
* For repositories that only need read operations (e.g., views, reports)
*
* @template T - Entity type
*/
export interface IReadOnlyRepository<T> {
findById(
ctx: ServiceContext,
id: string,
options?: QueryOptions,
): Promise<T | null>;
findOne(
ctx: ServiceContext,
criteria: Partial<T>,
options?: QueryOptions,
): Promise<T | null>;
findAll(
ctx: ServiceContext,
filters?: PaginationOptions & Partial<T>,
options?: QueryOptions,
): Promise<PaginatedResult<T>>;
findMany(
ctx: ServiceContext,
criteria: Partial<T>,
options?: QueryOptions,
): Promise<T[]>;
count(
ctx: ServiceContext,
criteria?: Partial<T>,
options?: QueryOptions,
): Promise<number>;
exists(
ctx: ServiceContext,
id: string,
options?: QueryOptions,
): Promise<boolean>;
}
/**
* Write-only repository interface
*
* For repositories that only need write operations (e.g., event stores, audit logs)
*
* @template T - Entity type
*/
export interface IWriteOnlyRepository<T> {
create(ctx: ServiceContext, data: Partial<T>): Promise<T>;
createMany(ctx: ServiceContext, data: Partial<T>[]): Promise<T[]>;
update(ctx: ServiceContext, id: string, data: Partial<T>): Promise<T | null>;
updateMany(
ctx: ServiceContext,
criteria: Partial<T>,
data: Partial<T>,
): Promise<number>;
softDelete(ctx: ServiceContext, id: string): Promise<boolean>;
hardDelete(ctx: ServiceContext, id: string): Promise<boolean>;
deleteMany(ctx: ServiceContext, criteria: Partial<T>): Promise<number>;
}
/**
* Create pagination meta from count and options
*/
export function createPaginationMeta(
total: number,
page: number,
limit: number,
): PaginationMeta {
const totalPages = Math.ceil(total / limit);
return {
total,
page,
limit,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
};
}

View File

@ -1,30 +0,0 @@
/**
* Shared Repositories Module
*
* Exports repository interfaces, factory, and utility functions
* for the Platform Marketing Content application.
*
* @module platform-marketing-content/shared/repositories
*/
// Export all interfaces and types from base repository
export {
IBaseRepository,
IReadOnlyRepository,
IWriteOnlyRepository,
ServiceContext,
QueryOptions,
PaginationOptions,
PaginationMeta,
PaginatedResult,
createPaginationMeta,
} from './base.repository.interface';
// Export factory and decorators
export {
RepositoryFactory,
createRepositoryFactory,
InjectRepository,
RepositoryNotFoundError,
RepositoryAlreadyRegisteredError,
} from './repository.factory';

View File

@ -1,343 +0,0 @@
/**
* Repository Factory - Dependency Injection pattern for repositories
*
* @module platform-marketing-content/shared/repositories
*
* @example
* ```typescript
* import { RepositoryFactory, IBaseRepository } from '@shared/repositories';
*
* // Register repositories at app startup
* const factory = RepositoryFactory.getInstance();
* factory.register('ProjectRepository', new ProjectRepositoryImpl());
*
* // Get repository in services
* const projectRepo = factory.getRequired<IBaseRepository<Project>>('ProjectRepository');
* const project = await projectRepo.findById(ctx, 'project-id');
* ```
*/
/**
* Repository not found error
*/
export class RepositoryNotFoundError extends Error {
constructor(repositoryName: string) {
super(`Repository '${repositoryName}' not found in factory registry`);
this.name = 'RepositoryNotFoundError';
}
}
/**
* Repository already registered error
*/
export class RepositoryAlreadyRegisteredError extends Error {
constructor(repositoryName: string) {
super(
`Repository '${repositoryName}' is already registered. Use 'replace' to override.`,
);
this.name = 'RepositoryAlreadyRegisteredError';
}
}
/**
* Repository factory for managing repository instances
*
* Implements Singleton and Registry patterns for centralized
* repository management and dependency injection.
*
* @example
* ```typescript
* // Initialize factory
* const factory = RepositoryFactory.getInstance();
*
* // Register repositories
* factory.register('ProjectRepository', projectRepository);
* factory.register('ClientRepository', clientRepository);
*
* // Retrieve repositories
* const projectRepo = factory.get<IBaseRepository<Project>>('ProjectRepository');
* const clientRepo = factory.getRequired<IBaseRepository<Client>>('ClientRepository');
*
* // Check registration
* if (factory.has('BrandRepository')) {
* const brandRepo = factory.get<IBaseRepository<Brand>>('BrandRepository');
* }
* ```
*/
export class RepositoryFactory {
private static instance: RepositoryFactory;
private repositories: Map<string, unknown>;
/**
* Private constructor for Singleton pattern
*/
private constructor() {
this.repositories = new Map<string, unknown>();
}
/**
* Get singleton instance of RepositoryFactory
*
* @returns The singleton instance
*
* @example
* ```typescript
* const factory = RepositoryFactory.getInstance();
* ```
*/
public static getInstance(): RepositoryFactory {
if (!RepositoryFactory.instance) {
RepositoryFactory.instance = new RepositoryFactory();
}
return RepositoryFactory.instance;
}
/**
* Register a repository instance
*
* @param name - Unique repository identifier
* @param repository - Repository instance
* @throws {RepositoryAlreadyRegisteredError} If repository name already exists
*
* @example
* ```typescript
* factory.register('ProjectRepository', new ProjectRepository(dataSource));
* factory.register('ClientRepository', new ClientRepository(dataSource));
* ```
*/
public register<T>(name: string, repository: T): void {
if (this.repositories.has(name)) {
throw new RepositoryAlreadyRegisteredError(name);
}
this.repositories.set(name, repository);
}
/**
* Register or replace an existing repository
*
* @param name - Unique repository identifier
* @param repository - Repository instance
*
* @example
* ```typescript
* // Override existing repository for testing
* factory.replace('ProjectRepository', mockProjectRepository);
* ```
*/
public replace<T>(name: string, repository: T): void {
this.repositories.set(name, repository);
}
/**
* Get a repository instance (returns undefined if not found)
*
* @param name - Repository identifier
* @returns Repository instance or undefined
*
* @example
* ```typescript
* const projectRepo = factory.get<IBaseRepository<Project>>('ProjectRepository');
* if (projectRepo) {
* const project = await projectRepo.findById(ctx, projectId);
* }
* ```
*/
public get<T>(name: string): T | undefined {
return this.repositories.get(name) as T | undefined;
}
/**
* Get a required repository instance
*
* @param name - Repository identifier
* @returns Repository instance
* @throws {RepositoryNotFoundError} If repository not found
*
* @example
* ```typescript
* const projectRepo = factory.getRequired<IBaseRepository<Project>>('ProjectRepository');
* const project = await projectRepo.findById(ctx, projectId);
* ```
*/
public getRequired<T>(name: string): T {
const repository = this.repositories.get(name) as T | undefined;
if (!repository) {
throw new RepositoryNotFoundError(name);
}
return repository;
}
/**
* Check if a repository is registered
*
* @param name - Repository identifier
* @returns True if repository exists
*
* @example
* ```typescript
* if (factory.has('BrandRepository')) {
* const brandRepo = factory.get<IBaseRepository<Brand>>('BrandRepository');
* }
* ```
*/
public has(name: string): boolean {
return this.repositories.has(name);
}
/**
* Unregister a repository
*
* @param name - Repository identifier
* @returns True if repository was removed
*
* @example
* ```typescript
* factory.unregister('TempRepository');
* ```
*/
public unregister(name: string): boolean {
return this.repositories.delete(name);
}
/**
* Clear all registered repositories
*
* Useful for testing scenarios
*
* @example
* ```typescript
* afterEach(() => {
* factory.clear();
* });
* ```
*/
public clear(): void {
this.repositories.clear();
}
/**
* Get all registered repository names
*
* @returns Array of repository names
*
* @example
* ```typescript
* const names = factory.getRegisteredNames();
* console.log('Registered repositories:', names);
* ```
*/
public getRegisteredNames(): string[] {
return Array.from(this.repositories.keys());
}
/**
* Get count of registered repositories
*
* @returns Number of registered repositories
*
* @example
* ```typescript
* console.log(`Total repositories: ${factory.count()}`);
* ```
*/
public count(): number {
return this.repositories.size;
}
/**
* Register multiple repositories at once
*
* @param repositories - Map of repository name to instance
*
* @example
* ```typescript
* factory.registerBatch({
* ProjectRepository: new ProjectRepository(dataSource),
* ClientRepository: new ClientRepository(dataSource),
* BrandRepository: new BrandRepository(dataSource),
* });
* ```
*/
public registerBatch(repositories: Record<string, unknown>): void {
Object.entries(repositories).forEach(([name, repository]) => {
this.register(name, repository);
});
}
/**
* Clone factory instance with same repositories
*
* Useful for creating isolated scopes in testing
*
* @returns New factory instance with cloned registry
*
* @example
* ```typescript
* const testFactory = factory.clone();
* testFactory.replace('ProjectRepository', mockProjectRepository);
* ```
*/
public clone(): RepositoryFactory {
const cloned = new RepositoryFactory();
this.repositories.forEach((repository, name) => {
cloned.register(name, repository);
});
return cloned;
}
}
/**
* Helper function to create and configure a repository factory
*
* @param repositories - Optional initial repositories
* @returns Configured RepositoryFactory instance
*
* @example
* ```typescript
* const factory = createRepositoryFactory({
* ProjectRepository: new ProjectRepository(dataSource),
* ClientRepository: new ClientRepository(dataSource),
* });
* ```
*/
export function createRepositoryFactory(
repositories?: Record<string, unknown>,
): RepositoryFactory {
const factory = RepositoryFactory.getInstance();
if (repositories) {
factory.registerBatch(repositories);
}
return factory;
}
/**
* Decorator for automatic repository injection
*
* @param repositoryName - Name of repository to inject
* @returns Property decorator
*
* @example
* ```typescript
* class ProjectService {
* @InjectRepository('ProjectRepository')
* private projectRepository: IBaseRepository<Project>;
*
* async getProject(ctx: ServiceContext, id: string) {
* return this.projectRepository.findById(ctx, id);
* }
* }
* ```
*/
export function InjectRepository(repositoryName: string) {
return function (target: any, propertyKey: string) {
Object.defineProperty(target, propertyKey, {
get() {
return RepositoryFactory.getInstance().getRequired(repositoryName);
},
enumerable: true,
configurable: true,
});
};
}

View File

@ -1,97 +0,0 @@
import { Repository, FindOptionsWhere, DeepPartial } from 'typeorm';
import { NotFoundException } from '@nestjs/common';
/**
* Base service para todos los servicios multi-tenant.
* Proporciona operaciones CRUD con filtrado automatico por tenant.
*
* @example
* @Injectable()
* export class ClientService extends TenantAwareService<Client> {
* constructor(
* @InjectRepository(Client)
* private readonly clientRepository: Repository<Client>,
* ) {
* super(clientRepository, 'Client');
* }
* }
*/
export abstract class TenantAwareService<T extends { tenant_id: string }> {
constructor(
protected readonly repository: Repository<T>,
protected readonly entityName: string = 'Entity',
) {}
/**
* Obtiene todos los registros del tenant
*/
async findAllByTenant(tenantId: string): Promise<T[]> {
return this.repository.find({
where: { tenant_id: tenantId } as FindOptionsWhere<T>,
});
}
/**
* Obtiene un registro por ID dentro del tenant
*/
async findOneByTenant(tenantId: string, id: string): Promise<T | null> {
return this.repository.findOne({
where: { id, tenant_id: tenantId } as unknown as FindOptionsWhere<T>,
});
}
/**
* Obtiene un registro o lanza NotFoundException
*/
async findOneOrFail(tenantId: string, id: string): Promise<T> {
const entity = await this.findOneByTenant(tenantId, id);
if (!entity) {
throw new NotFoundException(`${this.entityName} not found`);
}
return entity;
}
/**
* Crea un nuevo registro para el tenant
*/
async createForTenant(tenantId: string, data: DeepPartial<T>): Promise<T> {
const entity = this.repository.create({
...data,
tenant_id: tenantId,
} as DeepPartial<T>);
return this.repository.save(entity);
}
/**
* Actualiza un registro del tenant
*/
async updateForTenant(
tenantId: string,
id: string,
data: DeepPartial<T>,
): Promise<T> {
const entity = await this.findOneOrFail(tenantId, id);
Object.assign(entity, data);
return this.repository.save(entity);
}
/**
* Elimina (soft delete) un registro del tenant
*/
async removeForTenant(tenantId: string, id: string): Promise<void> {
await this.findOneOrFail(tenantId, id);
await this.repository.softDelete({
id,
tenant_id: tenantId,
} as unknown as FindOptionsWhere<T>);
}
/**
* Cuenta registros del tenant
*/
async countByTenant(tenantId: string): Promise<number> {
return this.repository.count({
where: { tenant_id: tenantId } as FindOptionsWhere<T>,
});
}
}

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