refactor: Configure subrepositorios
This commit is contained in:
parent
d0f47896e7
commit
dfa82a276e
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# SUBREPOSITORIOS
|
||||||
|
apps/
|
||||||
|
|
||||||
|
# Dependencias
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
9
.gitmodules
vendored
Normal file
9
.gitmodules
vendored
Normal 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
|
||||||
@ -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
|
|
||||||
@ -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: '^_' }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
28
apps/backend/.gitignore
vendored
28
apps/backend/.gitignore
vendored
@ -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
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"tabWidth": 2,
|
|
||||||
"semi": true,
|
|
||||||
"printWidth": 100
|
|
||||||
}
|
|
||||||
@ -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"]
|
|
||||||
@ -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;
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
|
||||||
"collection": "@nestjs/schematics",
|
|
||||||
"sourceRoot": "src",
|
|
||||||
"compilerOptions": {
|
|
||||||
"deleteOutDir": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11341
apps/backend/package-lock.json
generated
11341
apps/backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@ -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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@ -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);
|
|
||||||
@ -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);
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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}'],
|
|
||||||
});
|
|
||||||
@ -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'),
|
|
||||||
});
|
|
||||||
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -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),
|
|
||||||
});
|
|
||||||
@ -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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -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();
|
|
||||||
@ -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 {}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
export * from './asset.controller';
|
|
||||||
export * from './folder.controller';
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export * from './create-asset.dto';
|
|
||||||
export * from './update-asset.dto';
|
|
||||||
export * from './create-folder.dto';
|
|
||||||
export * from './update-folder.dto';
|
|
||||||
@ -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)
|
|
||||||
) {}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
|
||||||
import { CreateFolderDto } from './create-folder.dto';
|
|
||||||
|
|
||||||
export class UpdateFolderDto extends PartialType(CreateFolderDto) {}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
export * from './asset.entity';
|
|
||||||
export * from './asset-folder.entity';
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
export * from './asset.service';
|
|
||||||
export * from './folder.service';
|
|
||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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 {}
|
|
||||||
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 {}
|
|
||||||
@ -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>;
|
|
||||||
}
|
|
||||||
@ -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>;
|
|
||||||
}
|
|
||||||
@ -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>;
|
|
||||||
}
|
|
||||||
@ -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),
|
|
||||||
) {}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
|
||||||
import { CreateClientDto } from './create-client.dto';
|
|
||||||
|
|
||||||
export class UpdateClientDto extends PartialType(CreateClientDto) {}
|
|
||||||
@ -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),
|
|
||||||
) {}
|
|
||||||
@ -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[];
|
|
||||||
}
|
|
||||||
@ -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[];
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
export * from './project.controller';
|
|
||||||
export * from './content-piece.controller';
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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';
|
|
||||||
@ -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)
|
|
||||||
) {}
|
|
||||||
@ -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)
|
|
||||||
) {}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
export * from './project.entity';
|
|
||||||
export * from './content-piece.entity';
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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 {}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
export * from './project.service';
|
|
||||||
export * from './content-piece.service';
|
|
||||||
@ -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, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
|
||||||
import { CreateTenantDto } from './create-tenant.dto';
|
|
||||||
|
|
||||||
export class UpdateTenantDto extends PartialType(CreateTenantDto) {}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 {}
|
|
||||||
@ -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];
|
|
||||||
@ -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',
|
|
||||||
}
|
|
||||||
@ -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';
|
|
||||||
@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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';
|
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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
Loading…
Reference in New Issue
Block a user