Initial commit
This commit is contained in:
commit
9a47cb6822
48
.env.example
Normal file
48
.env.example
Normal file
@ -0,0 +1,48 @@
|
||||
# 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
|
||||
26
.eslintrc.js
Normal file
26
.eslintrc.js
Normal file
@ -0,0 +1,26 @@
|
||||
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
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
# 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
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@ -0,0 +1,37 @@
|
||||
# =============================================================================
|
||||
# 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"]
|
||||
32
jest.config.ts
Normal file
32
jest.config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
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;
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
11341
package-lock.json
generated
Normal file
11341
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
98
package.json
Normal file
98
package.json
Normal file
@ -0,0 +1,98 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
67
service.descriptor.yml
Normal file
67
service.descriptor.yml
Normal file
@ -0,0 +1,67 @@
|
||||
# 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
|
||||
64
src/__tests__/setup.ts
Normal file
64
src/__tests__/setup.ts
Normal file
@ -0,0 +1,64 @@
|
||||
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();
|
||||
});
|
||||
71
src/app.module.ts
Normal file
71
src/app.module.ts
Normal file
@ -0,0 +1,71 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
18
src/common/decorators/current-tenant.decorator.ts
Normal file
18
src/common/decorators/current-tenant.decorator.ts
Normal file
@ -0,0 +1,18 @@
|
||||
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;
|
||||
},
|
||||
);
|
||||
36
src/common/decorators/current-user.decorator.ts
Normal file
36
src/common/decorators/current-user.decorator.ts
Normal file
@ -0,0 +1,36 @@
|
||||
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;
|
||||
},
|
||||
);
|
||||
15
src/common/decorators/public.decorator.ts
Normal file
15
src/common/decorators/public.decorator.ts
Normal file
@ -0,0 +1,15 @@
|
||||
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);
|
||||
28
src/common/decorators/roles.decorator.ts
Normal file
28
src/common/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,28 @@
|
||||
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);
|
||||
70
src/common/filters/http-exception.filter.ts
Normal file
70
src/common/filters/http-exception.filter.ts
Normal file
@ -0,0 +1,70 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
40
src/common/guards/jwt-auth.guard.ts
Normal file
40
src/common/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,40 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
38
src/common/guards/roles.guard.ts
Normal file
38
src/common/guards/roles.guard.ts
Normal file
@ -0,0 +1,38 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
34
src/common/guards/tenant-member.guard.ts
Normal file
34
src/common/guards/tenant-member.guard.ts
Normal file
@ -0,0 +1,34 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
19
src/config/database.config.ts
Normal file
19
src/config/database.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
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}'],
|
||||
});
|
||||
17
src/config/jwt.config.ts
Normal file
17
src/config/jwt.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
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'),
|
||||
});
|
||||
21
src/config/redis.config.ts
Normal file
21
src/config/redis.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { BullModuleOptions } from '@nestjs/bull';
|
||||
|
||||
export const redisConfig = (
|
||||
configService: ConfigService,
|
||||
): BullModuleOptions => ({
|
||||
redis: {
|
||||
host: configService.get<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,
|
||||
},
|
||||
},
|
||||
});
|
||||
19
src/config/storage.config.ts
Normal file
19
src/config/storage.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
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),
|
||||
});
|
||||
83
src/config/swagger.config.ts
Normal file
83
src/config/swagger.config.ts
Normal file
@ -0,0 +1,83 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
56
src/main.ts
Normal file
56
src/main.ts
Normal file
@ -0,0 +1,56 @@
|
||||
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();
|
||||
13
src/modules/assets/assets.module.ts
Normal file
13
src/modules/assets/assets.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
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 {}
|
||||
136
src/modules/assets/controllers/asset.controller.ts
Normal file
136
src/modules/assets/controllers/asset.controller.ts
Normal file
@ -0,0 +1,136 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
125
src/modules/assets/controllers/folder.controller.ts
Normal file
125
src/modules/assets/controllers/folder.controller.ts
Normal file
@ -0,0 +1,125 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
2
src/modules/assets/controllers/index.ts
Normal file
2
src/modules/assets/controllers/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './asset.controller';
|
||||
export * from './folder.controller';
|
||||
95
src/modules/assets/dto/create-asset.dto.ts
Normal file
95
src/modules/assets/dto/create-asset.dto.ts
Normal file
@ -0,0 +1,95 @@
|
||||
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;
|
||||
}
|
||||
34
src/modules/assets/dto/create-folder.dto.ts
Normal file
34
src/modules/assets/dto/create-folder.dto.ts
Normal file
@ -0,0 +1,34 @@
|
||||
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;
|
||||
}
|
||||
4
src/modules/assets/dto/index.ts
Normal file
4
src/modules/assets/dto/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './create-asset.dto';
|
||||
export * from './update-asset.dto';
|
||||
export * from './create-folder.dto';
|
||||
export * from './update-folder.dto';
|
||||
6
src/modules/assets/dto/update-asset.dto.ts
Normal file
6
src/modules/assets/dto/update-asset.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
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)
|
||||
) {}
|
||||
4
src/modules/assets/dto/update-folder.dto.ts
Normal file
4
src/modules/assets/dto/update-folder.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateFolderDto } from './create-folder.dto';
|
||||
|
||||
export class UpdateFolderDto extends PartialType(CreateFolderDto) {}
|
||||
67
src/modules/assets/entities/asset-folder.entity.ts
Normal file
67
src/modules/assets/entities/asset-folder.entity.ts
Normal file
@ -0,0 +1,67 @@
|
||||
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;
|
||||
}
|
||||
102
src/modules/assets/entities/asset.entity.ts
Normal file
102
src/modules/assets/entities/asset.entity.ts
Normal file
@ -0,0 +1,102 @@
|
||||
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;
|
||||
}
|
||||
2
src/modules/assets/entities/index.ts
Normal file
2
src/modules/assets/entities/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './asset.entity';
|
||||
export * from './asset-folder.entity';
|
||||
144
src/modules/assets/services/asset.service.ts
Normal file
144
src/modules/assets/services/asset.service.ts
Normal file
@ -0,0 +1,144 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
175
src/modules/assets/services/folder.service.ts
Normal file
175
src/modules/assets/services/folder.service.ts
Normal file
@ -0,0 +1,175 @@
|
||||
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, '');
|
||||
}
|
||||
}
|
||||
2
src/modules/assets/services/index.ts
Normal file
2
src/modules/assets/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './asset.service';
|
||||
export * from './folder.service';
|
||||
240
src/modules/auth/__tests__/auth.service.spec.ts
Normal file
240
src/modules/auth/__tests__/auth.service.spec.ts
Normal file
@ -0,0 +1,240 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
30
src/modules/auth/auth.module.ts
Normal file
30
src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,30 @@
|
||||
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 {}
|
||||
99
src/modules/auth/controllers/auth.controller.ts
Normal file
99
src/modules/auth/controllers/auth.controller.ts
Normal file
@ -0,0 +1,99 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
46
src/modules/auth/dto/auth-response.dto.ts
Normal file
46
src/modules/auth/dto/auth-response.dto.ts
Normal file
@ -0,0 +1,46 @@
|
||||
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;
|
||||
}
|
||||
14
src/modules/auth/dto/login.dto.ts
Normal file
14
src/modules/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,14 @@
|
||||
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;
|
||||
}
|
||||
48
src/modules/auth/dto/register.dto.ts
Normal file
48
src/modules/auth/dto/register.dto.ts
Normal file
@ -0,0 +1,48 @@
|
||||
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;
|
||||
}
|
||||
46
src/modules/auth/entities/session.entity.ts
Normal file
46
src/modules/auth/entities/session.entity.ts
Normal file
@ -0,0 +1,46 @@
|
||||
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;
|
||||
}
|
||||
83
src/modules/auth/entities/user.entity.ts
Normal file
83
src/modules/auth/entities/user.entity.ts
Normal file
@ -0,0 +1,83 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
165
src/modules/auth/services/auth.service.ts
Normal file
165
src/modules/auth/services/auth.service.ts
Normal file
@ -0,0 +1,165 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
35
src/modules/auth/strategies/jwt.strategy.ts
Normal file
35
src/modules/auth/strategies/jwt.strategy.ts
Normal file
@ -0,0 +1,35 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
108
src/modules/crm/controllers/brand.controller.ts
Normal file
108
src/modules/crm/controllers/brand.controller.ts
Normal file
@ -0,0 +1,108 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
96
src/modules/crm/controllers/client.controller.ts
Normal file
96
src/modules/crm/controllers/client.controller.ts
Normal file
@ -0,0 +1,96 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
108
src/modules/crm/controllers/product.controller.ts
Normal file
108
src/modules/crm/controllers/product.controller.ts
Normal file
@ -0,0 +1,108 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
25
src/modules/crm/crm.module.ts
Normal file
25
src/modules/crm/crm.module.ts
Normal file
@ -0,0 +1,25 @@
|
||||
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 {}
|
||||
80
src/modules/crm/dto/create-brand.dto.ts
Normal file
80
src/modules/crm/dto/create-brand.dto.ts
Normal file
@ -0,0 +1,80 @@
|
||||
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>;
|
||||
}
|
||||
86
src/modules/crm/dto/create-client.dto.ts
Normal file
86
src/modules/crm/dto/create-client.dto.ts
Normal file
@ -0,0 +1,86 @@
|
||||
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>;
|
||||
}
|
||||
87
src/modules/crm/dto/create-product.dto.ts
Normal file
87
src/modules/crm/dto/create-product.dto.ts
Normal file
@ -0,0 +1,87 @@
|
||||
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>;
|
||||
}
|
||||
6
src/modules/crm/dto/update-brand.dto.ts
Normal file
6
src/modules/crm/dto/update-brand.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PartialType, OmitType } from '@nestjs/swagger';
|
||||
import { CreateBrandDto } from './create-brand.dto';
|
||||
|
||||
export class UpdateBrandDto extends PartialType(
|
||||
OmitType(CreateBrandDto, ['client_id'] as const),
|
||||
) {}
|
||||
4
src/modules/crm/dto/update-client.dto.ts
Normal file
4
src/modules/crm/dto/update-client.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateClientDto } from './create-client.dto';
|
||||
|
||||
export class UpdateClientDto extends PartialType(CreateClientDto) {}
|
||||
6
src/modules/crm/dto/update-product.dto.ts
Normal file
6
src/modules/crm/dto/update-product.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PartialType, OmitType } from '@nestjs/swagger';
|
||||
import { CreateProductDto } from './create-product.dto';
|
||||
|
||||
export class UpdateProductDto extends PartialType(
|
||||
OmitType(CreateProductDto, ['brand_id'] as const),
|
||||
) {}
|
||||
62
src/modules/crm/entities/brand.entity.ts
Normal file
62
src/modules/crm/entities/brand.entity.ts
Normal file
@ -0,0 +1,62 @@
|
||||
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[];
|
||||
}
|
||||
64
src/modules/crm/entities/client.entity.ts
Normal file
64
src/modules/crm/entities/client.entity.ts
Normal file
@ -0,0 +1,64 @@
|
||||
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[];
|
||||
}
|
||||
57
src/modules/crm/entities/product.entity.ts
Normal file
57
src/modules/crm/entities/product.entity.ts
Normal file
@ -0,0 +1,57 @@
|
||||
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;
|
||||
}
|
||||
140
src/modules/crm/services/brand.service.ts
Normal file
140
src/modules/crm/services/brand.service.ts
Normal file
@ -0,0 +1,140 @@
|
||||
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, '');
|
||||
}
|
||||
}
|
||||
116
src/modules/crm/services/client.service.ts
Normal file
116
src/modules/crm/services/client.service.ts
Normal file
@ -0,0 +1,116 @@
|
||||
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, '');
|
||||
}
|
||||
}
|
||||
141
src/modules/crm/services/product.service.ts
Normal file
141
src/modules/crm/services/product.service.ts
Normal file
@ -0,0 +1,141 @@
|
||||
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, '');
|
||||
}
|
||||
}
|
||||
175
src/modules/projects/controllers/content-piece.controller.ts
Normal file
175
src/modules/projects/controllers/content-piece.controller.ts
Normal file
@ -0,0 +1,175 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
2
src/modules/projects/controllers/index.ts
Normal file
2
src/modules/projects/controllers/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './project.controller';
|
||||
export * from './content-piece.controller';
|
||||
137
src/modules/projects/controllers/project.controller.ts
Normal file
137
src/modules/projects/controllers/project.controller.ts
Normal file
@ -0,0 +1,137 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
101
src/modules/projects/dto/create-content-piece.dto.ts
Normal file
101
src/modules/projects/dto/create-content-piece.dto.ts
Normal file
@ -0,0 +1,101 @@
|
||||
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>;
|
||||
}
|
||||
82
src/modules/projects/dto/create-project.dto.ts
Normal file
82
src/modules/projects/dto/create-project.dto.ts
Normal file
@ -0,0 +1,82 @@
|
||||
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;
|
||||
}
|
||||
4
src/modules/projects/dto/index.ts
Normal file
4
src/modules/projects/dto/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './create-project.dto';
|
||||
export * from './update-project.dto';
|
||||
export * from './create-content-piece.dto';
|
||||
export * from './update-content-piece.dto';
|
||||
6
src/modules/projects/dto/update-content-piece.dto.ts
Normal file
6
src/modules/projects/dto/update-content-piece.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PartialType, OmitType } from '@nestjs/swagger';
|
||||
import { CreateContentPieceDto } from './create-content-piece.dto';
|
||||
|
||||
export class UpdateContentPieceDto extends PartialType(
|
||||
OmitType(CreateContentPieceDto, ['project_id'] as const)
|
||||
) {}
|
||||
6
src/modules/projects/dto/update-project.dto.ts
Normal file
6
src/modules/projects/dto/update-project.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { PartialType, OmitType } from '@nestjs/swagger';
|
||||
import { CreateProjectDto } from './create-project.dto';
|
||||
|
||||
export class UpdateProjectDto extends PartialType(
|
||||
OmitType(CreateProjectDto, ['brand_id'] as const)
|
||||
) {}
|
||||
127
src/modules/projects/entities/content-piece.entity.ts
Normal file
127
src/modules/projects/entities/content-piece.entity.ts
Normal file
@ -0,0 +1,127 @@
|
||||
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;
|
||||
}
|
||||
2
src/modules/projects/entities/index.ts
Normal file
2
src/modules/projects/entities/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './project.entity';
|
||||
export * from './content-piece.entity';
|
||||
97
src/modules/projects/entities/project.entity.ts
Normal file
97
src/modules/projects/entities/project.entity.ts
Normal file
@ -0,0 +1,97 @@
|
||||
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;
|
||||
}
|
||||
13
src/modules/projects/projects.module.ts
Normal file
13
src/modules/projects/projects.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
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 {}
|
||||
188
src/modules/projects/services/content-piece.service.ts
Normal file
188
src/modules/projects/services/content-piece.service.ts
Normal file
@ -0,0 +1,188 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
2
src/modules/projects/services/index.ts
Normal file
2
src/modules/projects/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './project.service';
|
||||
export * from './content-piece.service';
|
||||
145
src/modules/projects/services/project.service.ts
Normal file
145
src/modules/projects/services/project.service.ts
Normal file
@ -0,0 +1,145 @@
|
||||
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, '');
|
||||
}
|
||||
}
|
||||
93
src/modules/tenants/controllers/tenants.controller.ts
Normal file
93
src/modules/tenants/controllers/tenants.controller.ts
Normal file
@ -0,0 +1,93 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
38
src/modules/tenants/dto/create-tenant.dto.ts
Normal file
38
src/modules/tenants/dto/create-tenant.dto.ts
Normal file
@ -0,0 +1,38 @@
|
||||
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;
|
||||
}
|
||||
4
src/modules/tenants/dto/update-tenant.dto.ts
Normal file
4
src/modules/tenants/dto/update-tenant.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateTenantDto } from './create-tenant.dto';
|
||||
|
||||
export class UpdateTenantDto extends PartialType(CreateTenantDto) {}
|
||||
65
src/modules/tenants/entities/tenant-plan.entity.ts
Normal file
65
src/modules/tenants/entities/tenant-plan.entity.ts
Normal file
@ -0,0 +1,65 @@
|
||||
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;
|
||||
}
|
||||
67
src/modules/tenants/entities/tenant.entity.ts
Normal file
67
src/modules/tenants/entities/tenant.entity.ts
Normal file
@ -0,0 +1,67 @@
|
||||
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;
|
||||
}
|
||||
119
src/modules/tenants/services/tenants.service.ts
Normal file
119
src/modules/tenants/services/tenants.service.ts
Normal file
@ -0,0 +1,119 @@
|
||||
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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
15
src/modules/tenants/tenants.module.ts
Normal file
15
src/modules/tenants/tenants.module.ts
Normal file
@ -0,0 +1,15 @@
|
||||
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 {}
|
||||
77
src/shared/constants/database.constants.ts
Normal file
77
src/shared/constants/database.constants.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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];
|
||||
109
src/shared/constants/enums.constants.ts
Normal file
109
src/shared/constants/enums.constants.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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',
|
||||
}
|
||||
36
src/shared/constants/index.ts
Normal file
36
src/shared/constants/index.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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';
|
||||
102
src/shared/dto/pagination.dto.ts
Normal file
102
src/shared/dto/pagination.dto.ts
Normal file
@ -0,0 +1,102 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
36
src/shared/entities/tenant-aware.entity.ts
Normal file
36
src/shared/entities/tenant-aware.entity.ts
Normal file
@ -0,0 +1,36 @@
|
||||
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;
|
||||
}
|
||||
272
src/shared/repositories/base.repository.interface.ts
Normal file
272
src/shared/repositories/base.repository.interface.ts
Normal file
@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
30
src/shared/repositories/index.ts
Normal file
30
src/shared/repositories/index.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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';
|
||||
343
src/shared/repositories/repository.factory.ts
Normal file
343
src/shared/repositories/repository.factory.ts
Normal file
@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
97
src/shared/services/tenant-aware.service.ts
Normal file
97
src/shared/services/tenant-aware.service.ts
Normal file
@ -0,0 +1,97 @@
|
||||
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>,
|
||||
});
|
||||
}
|
||||
}
|
||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@common/*": ["src/common/*"],
|
||||
"@config/*": ["src/config/*"],
|
||||
"@modules/*": ["src/modules/*"],
|
||||
"@shared/*": ["src/shared/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user