22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
src/app.controller.ts
Normal file
12
src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
202
src/app.module.ts
Normal file
202
src/app.module.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { redisStore } from 'cache-manager-redis-yet';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import {
|
||||
I18nModule,
|
||||
AcceptLanguageResolver,
|
||||
HeaderResolver,
|
||||
QueryResolver,
|
||||
} from 'nestjs-i18n';
|
||||
import * as path from 'path';
|
||||
|
||||
// Config
|
||||
import {
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
jwtConfig,
|
||||
redisConfig,
|
||||
i18nConfig,
|
||||
featuresConfig,
|
||||
throttleConfig,
|
||||
} from './config/configuration';
|
||||
import { geminiConfig } from './modules/gemini/gemini.config';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
|
||||
// Common
|
||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
|
||||
// Database
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
|
||||
// Modules
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { GeminiModule } from './modules/gemini/gemini.module';
|
||||
|
||||
// Guards
|
||||
import {
|
||||
JwtAuthGuard,
|
||||
RolesGuard,
|
||||
PermissionsGuard,
|
||||
} from './modules/auth/guards';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
load: [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
jwtConfig,
|
||||
redisConfig,
|
||||
i18nConfig,
|
||||
featuresConfig,
|
||||
throttleConfig,
|
||||
geminiConfig,
|
||||
],
|
||||
}),
|
||||
|
||||
// Logger (Structured Logging with Pino)
|
||||
LoggerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
pinoHttp: {
|
||||
level: configService.get('app.isDevelopment') ? 'debug' : 'info',
|
||||
transport: configService.get('app.isDevelopment')
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
singleLine: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
// i18n
|
||||
I18nModule.forRootAsync({
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
fallbackLanguage: configService.get('i18n.fallbackLanguage', 'en'),
|
||||
loaderOptions: {
|
||||
path: path.join(__dirname, '/i18n/'),
|
||||
watch: configService.get('app.isDevelopment', true),
|
||||
},
|
||||
}),
|
||||
resolvers: [
|
||||
new HeaderResolver(['x-lang', 'accept-language']),
|
||||
new QueryResolver(['lang']),
|
||||
AcceptLanguageResolver,
|
||||
],
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// Throttling
|
||||
ThrottlerModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => [
|
||||
{
|
||||
ttl: configService.get('throttle.ttl', 60000),
|
||||
limit: configService.get('throttle.limit', 100),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
// Caching (Redis with in-memory fallback)
|
||||
CacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
const useRedis = configService.get('REDIS_ENABLED', 'false') === 'true';
|
||||
|
||||
if (useRedis) {
|
||||
try {
|
||||
const store = await redisStore({
|
||||
socket: {
|
||||
host: configService.get('redis.host', 'localhost'),
|
||||
port: configService.get('redis.port', 6379),
|
||||
},
|
||||
ttl: 60 * 1000, // 1 minute default
|
||||
});
|
||||
console.log('✅ Redis cache connected');
|
||||
return {
|
||||
store: store as unknown as any,
|
||||
ttl: 60 * 1000,
|
||||
};
|
||||
} catch {
|
||||
console.warn('⚠️ Redis connection failed, using in-memory cache');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to in-memory cache
|
||||
console.log('📦 Using in-memory cache');
|
||||
return {
|
||||
ttl: 60 * 1000,
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// Database
|
||||
DatabaseModule,
|
||||
|
||||
// Core Modules
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
AdminModule,
|
||||
|
||||
// Optional Modules (controlled by env variables)
|
||||
GeminiModule,
|
||||
HealthModule,
|
||||
],
|
||||
providers: [
|
||||
// Global Exception Filter
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: GlobalExceptionFilter,
|
||||
},
|
||||
|
||||
// Global Response Interceptor
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ResponseInterceptor,
|
||||
},
|
||||
|
||||
// Global Rate Limiting
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
|
||||
// Global JWT Auth Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
|
||||
// Global Roles Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RolesGuard,
|
||||
},
|
||||
|
||||
// Global Permissions Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: PermissionsGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
128
src/common/base/base.controller.ts
Normal file
128
src/common/base/base.controller.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
HttpCode,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiOperation,
|
||||
ApiOkResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiBadRequestResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { BaseService } from './base.service';
|
||||
import { PaginationDto } from '../dto/pagination.dto';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
createPaginatedResponse,
|
||||
} from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Generic base controller with common CRUD endpoints
|
||||
* Extend this class for entity-specific controllers
|
||||
*
|
||||
* Note: Use decorators like @Controller() on the child class
|
||||
*/
|
||||
export abstract class BaseController<T, CreateDto, UpdateDto> {
|
||||
constructor(
|
||||
protected readonly service: BaseService<T, CreateDto, UpdateDto>,
|
||||
protected readonly entityName: string,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Get all records with pagination' })
|
||||
@ApiOkResponse({ description: 'Records retrieved successfully' })
|
||||
async findAll(
|
||||
@Query() pagination: PaginationDto,
|
||||
): Promise<ApiResponse<{ items: T[]; meta: any }>> {
|
||||
const result = await this.service.findAll(pagination);
|
||||
return createPaginatedResponse(
|
||||
result.items,
|
||||
result.meta.total,
|
||||
result.meta.page,
|
||||
result.meta.limit,
|
||||
`${this.entityName} list retrieved successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Get a record by ID' })
|
||||
@ApiOkResponse({ description: 'Record retrieved successfully' })
|
||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.findOne(id);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} retrieved successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Create a new record' })
|
||||
@ApiOkResponse({ description: 'Record created successfully' })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
async create(@Body() createDto: CreateDto): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.create(createDto);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} created successfully`,
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Update an existing record' })
|
||||
@ApiOkResponse({ description: 'Record updated successfully' })
|
||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() updateDto: UpdateDto,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.update(id, updateDto);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} updated successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Delete a record (soft delete)' })
|
||||
@ApiOkResponse({ description: 'Record deleted successfully' })
|
||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
||||
async delete(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.delete(id);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} deleted successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Restore a soft-deleted record' })
|
||||
@ApiOkResponse({ description: 'Record restored successfully' })
|
||||
async restore(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.restore(id);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} restored successfully`,
|
||||
);
|
||||
}
|
||||
}
|
||||
165
src/common/base/base.service.ts
Normal file
165
src/common/base/base.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { NotFoundException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { PaginationDto } from '../dto/pagination.dto';
|
||||
import { PaginationMeta } from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Generic base service with common CRUD operations
|
||||
* Extend this class for entity-specific services
|
||||
*/
|
||||
export abstract class BaseService<T, CreateDto, UpdateDto> {
|
||||
protected readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
protected readonly prisma: PrismaService,
|
||||
protected readonly modelName: string,
|
||||
) {
|
||||
this.logger = new Logger(`${modelName}Service`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Prisma model delegate
|
||||
*/
|
||||
protected get model() {
|
||||
return (this.prisma as any)[this.modelName.toLowerCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all records with pagination
|
||||
*/
|
||||
async findAll(
|
||||
pagination: PaginationDto,
|
||||
where?: any,
|
||||
): Promise<{ items: T[]; meta: PaginationMeta }> {
|
||||
const { skip, take, orderBy } = pagination;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.model.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy,
|
||||
}),
|
||||
this.model.count({ where }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(total / take);
|
||||
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
total,
|
||||
page: pagination.page || 1,
|
||||
limit: pagination.limit || 10,
|
||||
totalPages,
|
||||
hasNextPage: (pagination.page || 1) < totalPages,
|
||||
hasPreviousPage: (pagination.page || 1) > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record by ID
|
||||
*/
|
||||
async findOne(id: string, include?: any): Promise<T> {
|
||||
const record = await this.model.findUnique({
|
||||
where: { id },
|
||||
include,
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException(`${this.modelName} not found`);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record by custom criteria
|
||||
*/
|
||||
findOneBy(where: any, include?: any): Promise<T | null> {
|
||||
return this.model.findFirst({
|
||||
where,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record
|
||||
*/
|
||||
create(data: CreateDto, include?: any): Promise<T> {
|
||||
return this.model.create({
|
||||
data,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing record
|
||||
*/
|
||||
async update(id: string, data: UpdateDto, include?: any): Promise<T> {
|
||||
// Check if record exists
|
||||
await this.findOne(id);
|
||||
|
||||
return this.model.update({
|
||||
where: { id },
|
||||
data,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a record (sets deletedAt)
|
||||
*/
|
||||
async delete(id: string): Promise<T> {
|
||||
// Check if record exists
|
||||
await this.findOne(id);
|
||||
|
||||
return this.model.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete a record (permanently removes)
|
||||
*/
|
||||
async hardDelete(id: string): Promise<T> {
|
||||
// Check if record exists
|
||||
await this.findOne(id);
|
||||
|
||||
return this.prisma.hardDelete(this.modelName, { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft-deleted record
|
||||
*/
|
||||
async restore(id: string): Promise<T> {
|
||||
return this.prisma.restore(this.modelName, { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a record exists
|
||||
*/
|
||||
async exists(id: string): Promise<boolean> {
|
||||
const count = await this.model.count({
|
||||
where: { id },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count records matching criteria
|
||||
*/
|
||||
count(where?: any): Promise<number> {
|
||||
return this.model.count({ where });
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transaction
|
||||
*/
|
||||
transaction<R>(fn: (prisma: PrismaService) => Promise<R>): Promise<R> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
return fn(tx as unknown as PrismaService);
|
||||
});
|
||||
}
|
||||
}
|
||||
2
src/common/base/index.ts
Normal file
2
src/common/base/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './base.service';
|
||||
export * from './base.controller';
|
||||
60
src/common/decorators/index.ts
Normal file
60
src/common/decorators/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Get the current authenticated user from request
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: string | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (data) {
|
||||
return user?.[data];
|
||||
}
|
||||
|
||||
return user;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Mark a route as public (no authentication required)
|
||||
*/
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
/**
|
||||
* Require specific roles to access a route
|
||||
*/
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
/**
|
||||
* Require specific permissions to access a route
|
||||
*/
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const RequirePermissions = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
/**
|
||||
* Get tenant ID from request (for multi-tenancy)
|
||||
*/
|
||||
export const CurrentTenant = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.tenantId;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the current language from request headers
|
||||
*/
|
||||
export const CurrentLang = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.headers['accept-language'] || 'en';
|
||||
},
|
||||
);
|
||||
65
src/common/dto/pagination.dto.ts
Normal file
65
src/common/dto/pagination.dto.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { IsOptional, IsInt, Min, Max, IsString, IsIn } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PaginationDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1, description: 'Page number' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: 10,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
description: 'Items per page',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 10;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Field to sort by' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: string = 'createdAt';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['asc', 'desc'],
|
||||
default: 'desc',
|
||||
description: 'Sort order',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
sortOrder?: 'asc' | 'desc' = 'desc';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Search query' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
/**
|
||||
* Get skip value for Prisma
|
||||
*/
|
||||
get skip(): number {
|
||||
return ((this.page || 1) - 1) * (this.limit || 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get take value for Prisma
|
||||
*/
|
||||
get take(): number {
|
||||
return this.limit || 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orderBy object for Prisma
|
||||
*/
|
||||
get orderBy(): Record<string, 'asc' | 'desc'> {
|
||||
return { [this.sortBy || 'createdAt']: this.sortOrder || 'desc' };
|
||||
}
|
||||
}
|
||||
109
src/common/filters/global-exception.filter.ts
Normal file
109
src/common/filters/global-exception.filter.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { I18nService, I18nContext } from 'nestjs-i18n';
|
||||
import { ApiResponse, createErrorResponse } from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Global exception filter that catches all exceptions
|
||||
* and returns a standardized ApiResponse with HTTP 200
|
||||
*/
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
|
||||
constructor(private readonly i18n?: I18nService) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
// Determine status and message
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let errors: string[] = [];
|
||||
|
||||
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 Record<string, unknown>;
|
||||
message = (responseObj.message as string) || exception.message;
|
||||
|
||||
// Handle validation errors (class-validator)
|
||||
if (Array.isArray(responseObj.message)) {
|
||||
errors = responseObj.message as string[];
|
||||
message = 'VALIDATION_FAILED';
|
||||
}
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
// Try to translate the message
|
||||
if (this.i18n) {
|
||||
try {
|
||||
const i18nContext = I18nContext.current();
|
||||
let lang = i18nContext?.lang;
|
||||
|
||||
if (!lang) {
|
||||
const acceptLanguage = request.headers['accept-language'];
|
||||
const xLang = request.headers['x-lang'];
|
||||
|
||||
if (xLang) {
|
||||
lang = Array.isArray(xLang) ? xLang[0] : xLang;
|
||||
} else if (acceptLanguage) {
|
||||
// Take first preferred language: "tr-TR,en;q=0.9" -> "tr"
|
||||
lang = acceptLanguage.split(',')[0].split(';')[0].split('-')[0];
|
||||
}
|
||||
}
|
||||
|
||||
lang = lang || 'en';
|
||||
|
||||
// Translate validation error specially
|
||||
if (message === 'VALIDATION_FAILED') {
|
||||
message = this.i18n.translate('errors.VALIDATION_FAILED', { lang });
|
||||
} else {
|
||||
// Try dynamic translation
|
||||
const translatedMessage = this.i18n.translate(`errors.${message}`, {
|
||||
lang,
|
||||
});
|
||||
// Only update if translation exists (key is different from result)
|
||||
if (translatedMessage !== `errors.${message}`) {
|
||||
message = translatedMessage as string;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep original message if translation fails
|
||||
}
|
||||
}
|
||||
|
||||
// Log the error
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} - ${status} - ${message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
|
||||
// Build response
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
const errorResponse: ApiResponse<null> = createErrorResponse(
|
||||
message,
|
||||
status,
|
||||
errors,
|
||||
isDevelopment && exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
|
||||
// Always return HTTP 200, actual status in body
|
||||
response.status(200).json(errorResponse);
|
||||
}
|
||||
}
|
||||
74
src/common/interceptors/response.interceptor.ts
Normal file
74
src/common/interceptors/response.interceptor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ApiResponse, createSuccessResponse } from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Response interceptor that wraps all successful responses
|
||||
* in the standard ApiResponse format
|
||||
*/
|
||||
import { I18nService, I18nContext } from 'nestjs-i18n';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
ApiResponse<T>
|
||||
> {
|
||||
constructor(private readonly i18n: I18nService) {}
|
||||
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<ApiResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data: unknown) => {
|
||||
// If data is already an ApiResponse, return as-is
|
||||
if (this.isApiResponse(data)) {
|
||||
return data as ApiResponse<T>;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// Determine language
|
||||
const i18nContext = I18nContext.current();
|
||||
let lang = i18nContext?.lang;
|
||||
|
||||
if (!lang) {
|
||||
const acceptLanguage = request.headers['accept-language'];
|
||||
const xLang = request.headers['x-lang'];
|
||||
|
||||
if (xLang) {
|
||||
lang = Array.isArray(xLang) ? xLang[0] : xLang;
|
||||
} else if (acceptLanguage) {
|
||||
lang = acceptLanguage.split(',')[0].split(';')[0].split('-')[0];
|
||||
}
|
||||
}
|
||||
|
||||
lang = lang || 'en';
|
||||
|
||||
const message = this.i18n.translate('common.success', {
|
||||
lang,
|
||||
});
|
||||
|
||||
// Wrap in success response
|
||||
return createSuccessResponse(data as T, message);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private isApiResponse(data: unknown): boolean {
|
||||
return (
|
||||
data !== null &&
|
||||
typeof data === 'object' &&
|
||||
'success' in data &&
|
||||
'status' in data &&
|
||||
'message' in data &&
|
||||
'data' in data
|
||||
);
|
||||
}
|
||||
}
|
||||
96
src/common/types/api-response.type.ts
Normal file
96
src/common/types/api-response.type.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Standard API Response Type
|
||||
* All responses return HTTP 200 with this structure
|
||||
*/
|
||||
export type ApiResponse<T = any> = {
|
||||
errors: any[];
|
||||
stack?: string;
|
||||
message: string;
|
||||
success: boolean;
|
||||
status: number;
|
||||
data: T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Paginated response wrapper
|
||||
*/
|
||||
export interface PaginatedData<T> {
|
||||
items: T[];
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a successful API response
|
||||
*/
|
||||
export function createSuccessResponse<T>(
|
||||
data: T,
|
||||
message = 'Success',
|
||||
status = 200,
|
||||
): ApiResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
status,
|
||||
message,
|
||||
data,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error API response
|
||||
*/
|
||||
export function createErrorResponse(
|
||||
message: string,
|
||||
status = 400,
|
||||
errors: any[] = [],
|
||||
stack?: string,
|
||||
): ApiResponse<null> {
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
message,
|
||||
data: null,
|
||||
errors,
|
||||
stack,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a paginated API response
|
||||
*/
|
||||
export function createPaginatedResponse<T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
message = 'Success',
|
||||
): ApiResponse<PaginatedData<T>> {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: 200,
|
||||
message,
|
||||
data: {
|
||||
items,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
57
src/config/configuration.ts
Normal file
57
src/config/configuration.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export const appConfig = registerAs('app', () => ({
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
isDevelopment: process.env.NODE_ENV === 'development',
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
}));
|
||||
|
||||
export const databaseConfig = registerAs('database', () => ({
|
||||
url: process.env.DATABASE_URL,
|
||||
}));
|
||||
|
||||
export const jwtConfig = registerAs('jwt', () => ({
|
||||
secret: process.env.JWT_SECRET,
|
||||
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '15m',
|
||||
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '7d',
|
||||
}));
|
||||
|
||||
export const redisConfig = registerAs('redis', () => ({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
}));
|
||||
|
||||
export const i18nConfig = registerAs('i18n', () => ({
|
||||
defaultLanguage: process.env.DEFAULT_LANGUAGE || 'en',
|
||||
fallbackLanguage: process.env.FALLBACK_LANGUAGE || 'en',
|
||||
}));
|
||||
|
||||
export const featuresConfig = registerAs('features', () => ({
|
||||
mail: process.env.ENABLE_MAIL === 'true',
|
||||
s3: process.env.ENABLE_S3 === 'true',
|
||||
websocket: process.env.ENABLE_WEBSOCKET === 'true',
|
||||
multiTenancy: process.env.ENABLE_MULTI_TENANCY === 'true',
|
||||
}));
|
||||
|
||||
export const mailConfig = registerAs('mail', () => ({
|
||||
host: process.env.MAIL_HOST,
|
||||
port: parseInt(process.env.MAIL_PORT || '587', 10),
|
||||
user: process.env.MAIL_USER,
|
||||
password: process.env.MAIL_PASSWORD,
|
||||
from: process.env.MAIL_FROM,
|
||||
}));
|
||||
|
||||
export const s3Config = registerAs('s3', () => ({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
accessKey: process.env.S3_ACCESS_KEY,
|
||||
secretKey: process.env.S3_SECRET_KEY,
|
||||
bucket: process.env.S3_BUCKET,
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
}));
|
||||
|
||||
export const throttleConfig = registerAs('throttle', () => ({
|
||||
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
|
||||
limit: parseInt(process.env.THROTTLE_LIMIT || '100', 10),
|
||||
}));
|
||||
80
src/config/env.validation.ts
Normal file
80
src/config/env.validation.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Helper to parse boolean from string
|
||||
*/
|
||||
const booleanString = z
|
||||
.string()
|
||||
.optional()
|
||||
.default('false')
|
||||
.transform((val) => val === 'true');
|
||||
|
||||
/**
|
||||
* Environment variables schema validation using Zod
|
||||
*/
|
||||
export const envSchema = z.object({
|
||||
// Environment
|
||||
NODE_ENV: z
|
||||
.enum(['development', 'production', 'test'])
|
||||
.default('development'),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
// JWT
|
||||
JWT_SECRET: z.string().min(32),
|
||||
JWT_ACCESS_EXPIRATION: z.string().default('15m'),
|
||||
JWT_REFRESH_EXPIRATION: z.string().default('7d'),
|
||||
|
||||
// Redis
|
||||
REDIS_HOST: z.string().default('localhost'),
|
||||
REDIS_PORT: z.coerce.number().default(6379),
|
||||
REDIS_PASSWORD: z.string().optional(),
|
||||
|
||||
// i18n
|
||||
DEFAULT_LANGUAGE: z.string().default('en'),
|
||||
FALLBACK_LANGUAGE: z.string().default('en'),
|
||||
|
||||
// Optional Features
|
||||
ENABLE_MAIL: booleanString,
|
||||
ENABLE_S3: booleanString,
|
||||
ENABLE_WEBSOCKET: booleanString,
|
||||
ENABLE_MULTI_TENANCY: booleanString,
|
||||
|
||||
// Mail (Optional)
|
||||
MAIL_HOST: z.string().optional(),
|
||||
MAIL_PORT: z.coerce.number().optional(),
|
||||
MAIL_USER: z.string().optional(),
|
||||
MAIL_PASSWORD: z.string().optional(),
|
||||
MAIL_FROM: z.string().optional(),
|
||||
|
||||
// S3 (Optional)
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
S3_ACCESS_KEY: z.string().optional(),
|
||||
S3_SECRET_KEY: z.string().optional(),
|
||||
S3_BUCKET: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
|
||||
// Throttle
|
||||
THROTTLE_TTL: z.coerce.number().default(60000),
|
||||
THROTTLE_LIMIT: z.coerce.number().default(100),
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof envSchema>;
|
||||
|
||||
/**
|
||||
* Validate environment variables
|
||||
*/
|
||||
export function validateEnv(config: Record<string, unknown>): EnvConfig {
|
||||
const result = envSchema.safeParse(config);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map(
|
||||
(err) => `${err.path.join('.')}: ${err.message}`,
|
||||
);
|
||||
throw new Error(`Environment validation failed:\n${errors.join('\n')}`);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
9
src/database/database.module.ts
Normal file
9
src/database/database.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
134
src/database/prisma.service.ts
Normal file
134
src/database/prisma.service.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Injectable,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Models that support soft delete
|
||||
const SOFT_DELETE_MODELS = ['user', 'role', 'tenant'];
|
||||
|
||||
// Type for Prisma model delegate with common operations
|
||||
interface PrismaDelegate {
|
||||
delete: (args: { where: Record<string, unknown> }) => Promise<unknown>;
|
||||
findMany: (args?: Record<string, unknown>) => Promise<unknown[]>;
|
||||
update: (args: {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
log: [
|
||||
{ emit: 'event', level: 'query' },
|
||||
{ emit: 'event', level: 'error' },
|
||||
{ emit: 'event', level: 'warn' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log(
|
||||
`Connecting to database... URL: ${process.env.DATABASE_URL?.split('@')[1]}`,
|
||||
); // Mask password
|
||||
try {
|
||||
await this.$connect();
|
||||
this.logger.log('✅ Database connected successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`❌ Database connection failed: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
this.logger.log('🔌 Database disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model has soft delete (deletedAt field)
|
||||
*/
|
||||
hasSoftDelete(model: string | undefined): boolean {
|
||||
return model ? SOFT_DELETE_MODELS.includes(model.toLowerCase()) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete - actually remove from database
|
||||
*/
|
||||
hardDelete<T>(model: string, where: Record<string, unknown>): Promise<T> {
|
||||
const delegate = this.getModelDelegate(model);
|
||||
return delegate.delete({ where }) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find including soft deleted records
|
||||
*/
|
||||
findWithDeleted<T>(
|
||||
model: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<T[]> {
|
||||
const delegate = this.getModelDelegate(model);
|
||||
return delegate.findMany(args) as Promise<T[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft deleted record
|
||||
*/
|
||||
restore<T>(model: string, where: Record<string, unknown>): Promise<T> {
|
||||
const delegate = this.getModelDelegate(model);
|
||||
return delegate.update({
|
||||
where,
|
||||
data: { deletedAt: null },
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete - set deletedAt to current date
|
||||
*/
|
||||
softDelete<T>(model: string, where: Record<string, unknown>): Promise<T> {
|
||||
const delegate = this.getModelDelegate(model);
|
||||
return delegate.update({
|
||||
where,
|
||||
data: { deletedAt: new Date() },
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find many excluding soft deleted records
|
||||
*/
|
||||
findManyActive<T>(
|
||||
model: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<T[]> {
|
||||
const delegate = this.getModelDelegate(model);
|
||||
const whereWithDeleted = {
|
||||
...args,
|
||||
where: {
|
||||
...(args?.where as Record<string, unknown> | undefined),
|
||||
deletedAt: null,
|
||||
},
|
||||
};
|
||||
return delegate.findMany(whereWithDeleted) as Promise<T[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Prisma model delegate by name
|
||||
*/
|
||||
private getModelDelegate(model: string): PrismaDelegate {
|
||||
const modelKey = model.charAt(0).toLowerCase() + model.slice(1);
|
||||
|
||||
return (this as any)[modelKey] as PrismaDelegate;
|
||||
}
|
||||
}
|
||||
6
src/i18n/en/auth.json
Normal file
6
src/i18n/en/auth.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"registered": "User registered successfully",
|
||||
"login_success": "Login successful",
|
||||
"refresh_success": "Token refreshed successfully",
|
||||
"logout_success": "Logout successful"
|
||||
}
|
||||
13
src/i18n/en/common.json
Normal file
13
src/i18n/en/common.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"welcome": "Welcome",
|
||||
"success": "Operation completed successfully",
|
||||
"created": "Resource created successfully",
|
||||
"updated": "Resource updated successfully",
|
||||
"deleted": "Resource deleted successfully",
|
||||
"restored": "Resource restored successfully",
|
||||
"notFound": "Resource not found",
|
||||
"serverError": "An unexpected error occurred",
|
||||
"unauthorized": "You are not authorized to perform this action",
|
||||
"forbidden": "Access denied",
|
||||
"badRequest": "Invalid request"
|
||||
}
|
||||
14
src/i18n/en/errors.json
Normal file
14
src/i18n/en/errors.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"USER_NOT_FOUND": "User not found",
|
||||
"INVALID_CREDENTIALS": "Invalid email or password",
|
||||
"EMAIL_ALREADY_EXISTS": "This email is already registered",
|
||||
"INVALID_REFRESH_TOKEN": "Invalid or expired refresh token",
|
||||
"ACCOUNT_DISABLED": "Your account has been disabled",
|
||||
"TOKEN_EXPIRED": "Your session has expired, please login again",
|
||||
"PERMISSION_DENIED": "You do not have permission to perform this action",
|
||||
"ROLE_NOT_FOUND": "Role not found",
|
||||
"TENANT_NOT_FOUND": "Tenant not found",
|
||||
"VALIDATION_FAILED": "Validation failed",
|
||||
"INTERNAL_ERROR": "An internal error occurred, please try again later",
|
||||
"AUTH_REQUIRED": "Authentication required, please provide a valid token"
|
||||
}
|
||||
23
src/i18n/en/validation.json
Normal file
23
src/i18n/en/validation.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"email": {
|
||||
"required": "Email is required",
|
||||
"invalid": "Please enter a valid email address"
|
||||
},
|
||||
"password": {
|
||||
"required": "Password is required",
|
||||
"minLength": "Password must be at least 8 characters long",
|
||||
"weak": "Password is too weak"
|
||||
},
|
||||
"firstName": {
|
||||
"required": "First name is required"
|
||||
},
|
||||
"lastName": {
|
||||
"required": "Last name is required"
|
||||
},
|
||||
"generic": {
|
||||
"required": "This field is required",
|
||||
"invalid": "Invalid value",
|
||||
"minLength": "Must be at least {min} characters",
|
||||
"maxLength": "Must be at most {max} characters"
|
||||
}
|
||||
}
|
||||
6
src/i18n/tr/auth.json
Normal file
6
src/i18n/tr/auth.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"registered": "Kullanıcı başarıyla kaydedildi",
|
||||
"login_success": "Giriş başarılı",
|
||||
"refresh_success": "Token başarıyla yenilendi",
|
||||
"logout_success": "Çıkış başarılı"
|
||||
}
|
||||
13
src/i18n/tr/common.json
Normal file
13
src/i18n/tr/common.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"welcome": "Hoş geldiniz",
|
||||
"success": "İşlem başarıyla tamamlandı",
|
||||
"created": "Kayıt başarıyla oluşturuldu",
|
||||
"updated": "Kayıt başarıyla güncellendi",
|
||||
"deleted": "Kayıt başarıyla silindi",
|
||||
"restored": "Kayıt başarıyla geri yüklendi",
|
||||
"notFound": "Kayıt bulunamadı",
|
||||
"serverError": "Beklenmeyen bir hata oluştu",
|
||||
"unauthorized": "Bu işlemi yapmaya yetkiniz yok",
|
||||
"forbidden": "Erişim reddedildi",
|
||||
"badRequest": "Geçersiz istek"
|
||||
}
|
||||
14
src/i18n/tr/errors.json
Normal file
14
src/i18n/tr/errors.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"USER_NOT_FOUND": "Kullanıcı bulunamadı",
|
||||
"INVALID_CREDENTIALS": "Geçersiz e-posta veya şifre",
|
||||
"EMAIL_ALREADY_EXISTS": "Bu e-posta adresi zaten kayıtlı",
|
||||
"INVALID_REFRESH_TOKEN": "Geçersiz veya süresi dolmuş yenileme token'ı",
|
||||
"ACCOUNT_DISABLED": "Hesabınız devre dışı bırakılmış",
|
||||
"TOKEN_EXPIRED": "Oturumunuz sona erdi, lütfen tekrar giriş yapın",
|
||||
"PERMISSION_DENIED": "Bu işlemi gerçekleştirme izniniz yok",
|
||||
"ROLE_NOT_FOUND": "Rol bulunamadı",
|
||||
"TENANT_NOT_FOUND": "Kiracı bulunamadı",
|
||||
"VALIDATION_FAILED": "Doğrulama başarısız",
|
||||
"INTERNAL_ERROR": "Bir iç hata oluştu, lütfen daha sonra tekrar deneyin",
|
||||
"AUTH_REQUIRED": "Kimlik doğrulama gerekli, lütfen geçerli bir token sağlayın"
|
||||
}
|
||||
23
src/i18n/tr/validation.json
Normal file
23
src/i18n/tr/validation.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"email": {
|
||||
"required": "E-posta adresi gereklidir",
|
||||
"invalid": "Lütfen geçerli bir e-posta adresi girin"
|
||||
},
|
||||
"password": {
|
||||
"required": "Şifre gereklidir",
|
||||
"minLength": "Şifre en az 8 karakter olmalıdır",
|
||||
"weak": "Şifre çok zayıf"
|
||||
},
|
||||
"firstName": {
|
||||
"required": "Ad gereklidir"
|
||||
},
|
||||
"lastName": {
|
||||
"required": "Soyad gereklidir"
|
||||
},
|
||||
"generic": {
|
||||
"required": "Bu alan gereklidir",
|
||||
"invalid": "Geçersiz değer",
|
||||
"minLength": "En az {min} karakter olmalıdır",
|
||||
"maxLength": "En fazla {max} karakter olmalıdır"
|
||||
}
|
||||
}
|
||||
90
src/main.ts
Normal file
90
src/main.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger as NestLogger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import helmet from 'helmet';
|
||||
import { Logger, LoggerErrorInterceptor } from 'nestjs-pino';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new NestLogger('Bootstrap');
|
||||
|
||||
logger.log('🔄 Starting application...');
|
||||
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
|
||||
// Use Pino Logger
|
||||
app.useLogger(app.get(Logger));
|
||||
app.useGlobalInterceptors(new LoggerErrorInterceptor());
|
||||
|
||||
// Security Headers
|
||||
app.use(helmet());
|
||||
|
||||
// Graceful Shutdown (Prisma & Docker)
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Get config service
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('PORT', 3000);
|
||||
const nodeEnv = configService.get('NODE_ENV', 'development');
|
||||
|
||||
// Enable CORS
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Global prefix
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Validation pipe (Strict)
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Swagger setup
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('TypeScript Boilerplate API')
|
||||
.setDescription(
|
||||
'Senior-level NestJS backend boilerplate with generic CRUD, authentication, i18n, and Redis caching',
|
||||
)
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.addTag('Auth', 'Authentication endpoints')
|
||||
.addTag('Users', 'User management endpoints')
|
||||
.addTag('Admin', 'Admin management endpoints')
|
||||
.addTag('Health', 'Health check endpoints')
|
||||
.build();
|
||||
|
||||
logger.log('Initializing Swagger...');
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, document, {
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
},
|
||||
});
|
||||
logger.log('Swagger initialized');
|
||||
|
||||
logger.log(`Attempting to listen on port ${port}...`);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
|
||||
logger.log('═══════════════════════════════════════════════════════════');
|
||||
logger.log(`🚀 Server is running on: http://localhost:${port}/api`);
|
||||
logger.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
|
||||
logger.log(`💚 Health check: http://localhost:${port}/api/health`);
|
||||
logger.log(`🌍 Environment: ${nodeEnv.toUpperCase()}`);
|
||||
logger.log('═══════════════════════════════════════════════════════════');
|
||||
|
||||
if (nodeEnv === 'development') {
|
||||
logger.warn('⚠️ Running in development mode');
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
270
src/modules/admin/admin.controller.ts
Normal file
270
src/modules/admin/admin.controller.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CacheInterceptor,
|
||||
CacheKey,
|
||||
CacheTTL,
|
||||
CACHE_MANAGER,
|
||||
} from '@nestjs/cache-manager';
|
||||
import * as cacheManager from 'cache-manager';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { Roles } from '../../common/decorators';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
createPaginatedResponse,
|
||||
PaginatedData,
|
||||
} from '../../common/types/api-response.type';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { UserResponseDto } from '../users/dto/user.dto';
|
||||
import {
|
||||
PermissionResponseDto,
|
||||
RolePermissionResponseDto,
|
||||
RoleResponseDto,
|
||||
UserRoleResponseDto,
|
||||
} from './dto/admin.dto';
|
||||
|
||||
@ApiTags('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Controller('admin')
|
||||
@Roles('admin')
|
||||
export class AdminController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache,
|
||||
) {}
|
||||
|
||||
// ================== Users Management ==================
|
||||
|
||||
@Get('users')
|
||||
@ApiOperation({ summary: 'Get all users (admin)' })
|
||||
async getAllUsers(
|
||||
@Query() pagination: PaginationDto,
|
||||
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
|
||||
const { skip, take, orderBy } = pagination;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
skip,
|
||||
take,
|
||||
orderBy,
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count(),
|
||||
]);
|
||||
|
||||
const dtos = plainToInstance(
|
||||
UserResponseDto,
|
||||
users,
|
||||
) as unknown as UserResponseDto[];
|
||||
|
||||
return createPaginatedResponse(
|
||||
dtos,
|
||||
total,
|
||||
pagination.page || 1,
|
||||
pagination.limit || 10,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('users/:id/toggle-active')
|
||||
@ApiOperation({ summary: 'Toggle user active status' })
|
||||
async toggleUserActive(
|
||||
@Param('id') id: string,
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { isActive: !user?.isActive },
|
||||
});
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, updated),
|
||||
'User status updated',
|
||||
);
|
||||
}
|
||||
|
||||
@Post('users/:userId/roles/:roleId')
|
||||
@ApiOperation({ summary: 'Assign role to user' })
|
||||
async assignRole(
|
||||
@Param('userId') userId: string,
|
||||
@Param('roleId') roleId: string,
|
||||
): Promise<ApiResponse<UserRoleResponseDto>> {
|
||||
const userRole = await this.prisma.userRole.create({
|
||||
data: { userId, roleId },
|
||||
});
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserRoleResponseDto, userRole),
|
||||
'Role assigned to user',
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('users/:userId/roles/:roleId')
|
||||
@ApiOperation({ summary: 'Remove role from user' })
|
||||
async removeRole(
|
||||
@Param('userId') userId: string,
|
||||
@Param('roleId') roleId: string,
|
||||
): Promise<ApiResponse<null>> {
|
||||
await this.prisma.userRole.deleteMany({
|
||||
where: { userId, roleId },
|
||||
});
|
||||
return createSuccessResponse(null, 'Role removed from user');
|
||||
}
|
||||
|
||||
// ================== Roles Management ==================
|
||||
|
||||
@Get('roles')
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheKey('roles_list')
|
||||
@CacheTTL(60 * 1000)
|
||||
@ApiOperation({ summary: 'Get all roles' })
|
||||
async getAllRoles(): Promise<ApiResponse<RoleResponseDto[]>> {
|
||||
const roles = await this.prisma.role.findMany({
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { users: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Transform Prisma structure to DTO structure
|
||||
const transformedRoles = roles.map((role) => ({
|
||||
...role,
|
||||
permissions: role.permissions.map((rp) => rp.permission),
|
||||
}));
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(
|
||||
RoleResponseDto,
|
||||
transformedRoles,
|
||||
) as unknown as RoleResponseDto[],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('roles')
|
||||
@ApiOperation({ summary: 'Create a new role' })
|
||||
async createRole(
|
||||
@Body() data: { name: string; description?: string },
|
||||
): Promise<ApiResponse<RoleResponseDto>> {
|
||||
const role = await this.prisma.role.create({ data });
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(RoleResponseDto, role),
|
||||
'Role created',
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('roles/:id')
|
||||
@ApiOperation({ summary: 'Update a role' })
|
||||
async updateRole(
|
||||
@Param('id') id: string,
|
||||
@Body() data: { name?: string; description?: string },
|
||||
): Promise<ApiResponse<RoleResponseDto>> {
|
||||
const role = await this.prisma.role.update({ where: { id }, data });
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(RoleResponseDto, role),
|
||||
'Role updated',
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('roles/:id')
|
||||
@ApiOperation({ summary: 'Delete a role' })
|
||||
async deleteRole(@Param('id') id: string): Promise<ApiResponse<null>> {
|
||||
await this.prisma.role.delete({ where: { id } });
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(null, 'Role deleted');
|
||||
}
|
||||
|
||||
// ================== Permissions Management ==================
|
||||
|
||||
@Get('permissions')
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheKey('permissions_list')
|
||||
@CacheTTL(60 * 1000)
|
||||
@ApiOperation({ summary: 'Get all permissions' })
|
||||
async getAllPermissions(): Promise<ApiResponse<PermissionResponseDto[]>> {
|
||||
const permissions = await this.prisma.permission.findMany();
|
||||
return createSuccessResponse(
|
||||
plainToInstance(
|
||||
PermissionResponseDto,
|
||||
permissions,
|
||||
) as unknown as PermissionResponseDto[],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('permissions')
|
||||
@ApiOperation({ summary: 'Create a new permission' })
|
||||
async createPermission(
|
||||
@Body()
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
},
|
||||
): Promise<ApiResponse<PermissionResponseDto>> {
|
||||
const permission = await this.prisma.permission.create({ data });
|
||||
await this.cacheManager.del('permissions_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(PermissionResponseDto, permission),
|
||||
'Permission created',
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('roles/:roleId/permissions/:permissionId')
|
||||
@ApiOperation({ summary: 'Assign permission to role' })
|
||||
async assignPermission(
|
||||
@Param('roleId') roleId: string,
|
||||
@Param('permissionId') permissionId: string,
|
||||
): Promise<ApiResponse<RolePermissionResponseDto>> {
|
||||
const rolePermission = await this.prisma.rolePermission.create({
|
||||
data: { roleId, permissionId },
|
||||
});
|
||||
// Invalidate roles_list because permissions are nested in roles
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(RolePermissionResponseDto, rolePermission),
|
||||
'Permission assigned to role',
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('roles/:roleId/permissions/:permissionId')
|
||||
@ApiOperation({ summary: 'Remove permission from role' })
|
||||
async removePermission(
|
||||
@Param('roleId') roleId: string,
|
||||
@Param('permissionId') permissionId: string,
|
||||
): Promise<ApiResponse<null>> {
|
||||
await this.prisma.rolePermission.deleteMany({
|
||||
where: { roleId, permissionId },
|
||||
});
|
||||
// Invalidate roles_list because permissions are nested in roles
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(null, 'Permission removed from role');
|
||||
}
|
||||
}
|
||||
7
src/modules/admin/admin.module.ts
Normal file
7
src/modules/admin/admin.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
71
src/modules/admin/dto/admin.dto.ts
Normal file
71
src/modules/admin/dto/admin.dto.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Exclude, Expose, Type } from 'class-transformer';
|
||||
|
||||
@Exclude()
|
||||
export class PermissionResponseDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
description: string | null;
|
||||
|
||||
@Expose()
|
||||
resource: string;
|
||||
|
||||
@Expose()
|
||||
action: string;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
export class RoleResponseDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
description: string | null;
|
||||
|
||||
@Expose()
|
||||
@Type(() => PermissionResponseDto)
|
||||
permissions?: PermissionResponseDto[];
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
export class UserRoleResponseDto {
|
||||
@Expose()
|
||||
userId: string;
|
||||
|
||||
@Expose()
|
||||
roleId: string;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
export class RolePermissionResponseDto {
|
||||
@Expose()
|
||||
roleId: string;
|
||||
|
||||
@Expose()
|
||||
permissionId: string;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
}
|
||||
78
src/modules/auth/auth.controller.ts
Normal file
78
src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
|
||||
import { I18n, I18nContext } from 'nestjs-i18n';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
RegisterDto,
|
||||
LoginDto,
|
||||
RefreshTokenDto,
|
||||
TokenResponseDto,
|
||||
} from './dto/auth.dto';
|
||||
import { Public } from '../../common/decorators';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
} from '../../common/types/api-response.type';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@ApiOkResponse({
|
||||
description: 'User registered successfully',
|
||||
type: TokenResponseDto,
|
||||
})
|
||||
async register(
|
||||
@Body() dto: RegisterDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<TokenResponseDto>> {
|
||||
const result = await this.authService.register(dto);
|
||||
return createSuccessResponse(result, i18n.t('auth.registered'), 201);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@ApiOkResponse({ description: 'Login successful', type: TokenResponseDto })
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<TokenResponseDto>> {
|
||||
const result = await this.authService.login(dto);
|
||||
return createSuccessResponse(result, i18n.t('auth.login_success'));
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
@ApiOkResponse({
|
||||
description: 'Token refreshed successfully',
|
||||
type: TokenResponseDto,
|
||||
})
|
||||
async refreshToken(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<TokenResponseDto>> {
|
||||
const result = await this.authService.refreshToken(dto.refreshToken);
|
||||
return createSuccessResponse(result, i18n.t('auth.refresh_success'));
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Logout and invalidate refresh token' })
|
||||
@ApiOkResponse({ description: 'Logout successful' })
|
||||
async logout(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<null>> {
|
||||
await this.authService.logout(dto.refreshToken);
|
||||
return createSuccessResponse(null, i18n.t('auth.logout_success'));
|
||||
}
|
||||
}
|
||||
37
src/modules/auth/auth.module.ts
Normal file
37
src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { JwtAuthGuard, RolesGuard, PermissionsGuard } from './guards';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService): JwtModuleOptions => {
|
||||
const expiresIn =
|
||||
configService.get<string>('JWT_ACCESS_EXPIRATION') || '15m';
|
||||
return {
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: expiresIn as any,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
JwtAuthGuard,
|
||||
RolesGuard,
|
||||
PermissionsGuard,
|
||||
],
|
||||
exports: [AuthService, JwtAuthGuard, RolesGuard, PermissionsGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
336
src/modules/auth/auth.service.ts
Normal file
336
src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { RegisterDto, LoginDto, TokenResponseDto } from './dto/auth.dto';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
interface UserWithRoles {
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
isActive: boolean;
|
||||
tenantId: string | null;
|
||||
roles: Array<{
|
||||
role: {
|
||||
name: string;
|
||||
permissions: Array<{
|
||||
permission: {
|
||||
name: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
async register(dto: RegisterDto): Promise<TokenResponseDto> {
|
||||
// Check if email already exists
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('EMAIL_ALREADY_EXISTS');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await this.hashPassword(dto.password);
|
||||
|
||||
// Create user with default role
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
password: hashedPassword,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
roles: {
|
||||
create: {
|
||||
role: {
|
||||
connectOrCreate: {
|
||||
where: { name: 'user' },
|
||||
create: { name: 'user', description: 'Default user role' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.generateTokens(user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
async login(dto: LoginDto): Promise<TokenResponseDto> {
|
||||
// Find user by email
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await this.comparePassword(
|
||||
dto.password,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('ACCOUNT_DISABLED');
|
||||
}
|
||||
|
||||
return this.generateTokens(user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<TokenResponseDto> {
|
||||
// Find refresh token
|
||||
const storedToken = await this.prisma.refreshToken.findUnique({
|
||||
where: { token: refreshToken },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!storedToken) {
|
||||
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
if (storedToken.expiresAt < new Date()) {
|
||||
// Delete expired token
|
||||
await this.prisma.refreshToken.delete({
|
||||
where: { id: storedToken.id },
|
||||
});
|
||||
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
// Delete old refresh token
|
||||
await this.prisma.refreshToken.delete({
|
||||
where: { id: storedToken.id },
|
||||
});
|
||||
|
||||
return this.generateTokens(storedToken.user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - invalidate refresh token
|
||||
*/
|
||||
async logout(refreshToken: string): Promise<void> {
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
where: { token: refreshToken },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user by ID (used by JWT strategy)
|
||||
*/
|
||||
async validateUser(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove password from user object
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { password: _, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access and refresh tokens
|
||||
*/
|
||||
private async generateTokens(user: UserWithRoles): Promise<TokenResponseDto> {
|
||||
// Extract roles and permissions
|
||||
const roles = user.roles.map((ur) => ur.role.name);
|
||||
const permissions = user.roles.flatMap((ur) =>
|
||||
ur.role.permissions.map((rp) => rp.permission.name),
|
||||
);
|
||||
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
roles,
|
||||
permissions,
|
||||
tenantId: user.tenantId || undefined,
|
||||
};
|
||||
|
||||
// Generate access token
|
||||
const accessToken = this.jwtService.sign(payload, {
|
||||
expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
||||
});
|
||||
|
||||
// Generate refresh token
|
||||
const refreshTokenValue = crypto.randomUUID();
|
||||
const refreshExpiration = this.parseExpiration(
|
||||
this.configService.get('JWT_REFRESH_EXPIRATION', '7d'),
|
||||
);
|
||||
|
||||
// Store refresh token
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
token: refreshTokenValue,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + refreshExpiration),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: refreshTokenValue,
|
||||
expiresIn:
|
||||
this.parseExpiration(
|
||||
this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
||||
) / 1000, // Convert to seconds
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName || undefined,
|
||||
lastName: user.lastName || undefined,
|
||||
roles,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 12;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare password with hash
|
||||
*/
|
||||
private async comparePassword(
|
||||
password: string,
|
||||
hashedPassword: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hashedPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse expiration string to milliseconds
|
||||
*/
|
||||
private parseExpiration(expiration: string): number {
|
||||
const match = expiration.match(/^(\d+)([smhd])$/);
|
||||
if (!match) {
|
||||
return 15 * 60 * 1000; // Default 15 minutes
|
||||
}
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case 's':
|
||||
return value * 1000;
|
||||
case 'm':
|
||||
return value * 60 * 1000;
|
||||
case 'h':
|
||||
return value * 60 * 60 * 1000;
|
||||
case 'd':
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 15 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/modules/auth/dto/auth.dto.ts
Normal file
70
src/modules/auth/dto/auth.dto.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'password123', minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'John' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'password123' })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export class UserInfoDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
firstName?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
lastName?: string;
|
||||
|
||||
@ApiProperty()
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export class TokenResponseDto {
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
expiresIn: number;
|
||||
|
||||
@ApiProperty({ type: UserInfoDto })
|
||||
user: UserInfoDto;
|
||||
}
|
||||
129
src/modules/auth/guards/auth.guards.ts
Normal file
129
src/modules/auth/guards/auth.guards.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request } from 'express';
|
||||
import {
|
||||
IS_PUBLIC_KEY,
|
||||
ROLES_KEY,
|
||||
PERMISSIONS_KEY,
|
||||
} from '../../../common/decorators';
|
||||
|
||||
interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Auth Guard - Validates JWT token
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
// Check if route is public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest<TUser = AuthenticatedUser>(
|
||||
err: Error | null,
|
||||
user: TUser | false,
|
||||
info: any,
|
||||
): TUser {
|
||||
if (err || !user) {
|
||||
if (info?.name === 'TokenExpiredError') {
|
||||
throw new UnauthorizedException('TOKEN_EXPIRED');
|
||||
}
|
||||
throw err || new UnauthorizedException('AUTH_REQUIRED');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles Guard - Check if user has required roles
|
||||
*/
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as AuthenticatedUser | undefined;
|
||||
|
||||
if (!user || !user.roles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('PERMISSION_DENIED');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions Guard - Check if user has required permissions
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
||||
PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as AuthenticatedUser | undefined;
|
||||
|
||||
if (!user || !user.permissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasPermission = requiredPermissions.every((permission) =>
|
||||
user.permissions.includes(permission),
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new ForbiddenException('PERMISSION_DENIED');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
1
src/modules/auth/guards/index.ts
Normal file
1
src/modules/auth/guards/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './auth.guards';
|
||||
38
src/modules/auth/strategies/jwt.strategy.ts
Normal file
38
src/modules/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService, JwtPayload } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
const secret = configService.get<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
}
|
||||
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: secret,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.authService.validateUser(payload.sub);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
roles: payload.roles,
|
||||
permissions: payload.permissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
7
src/modules/gemini/gemini.config.ts
Normal file
7
src/modules/gemini/gemini.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export const geminiConfig = registerAs('gemini', () => ({
|
||||
enabled: process.env.ENABLE_GEMINI === 'true',
|
||||
apiKey: process.env.GOOGLE_API_KEY,
|
||||
defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
|
||||
}));
|
||||
18
src/modules/gemini/gemini.module.ts
Normal file
18
src/modules/gemini/gemini.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { GeminiService } from './gemini.service';
|
||||
import { geminiConfig } from './gemini.config';
|
||||
|
||||
/**
|
||||
* Gemini AI Module
|
||||
*
|
||||
* Optional module for AI-powered features using Google Gemini API.
|
||||
* Enable by setting ENABLE_GEMINI=true in your .env file.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule.forFeature(geminiConfig)],
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
})
|
||||
export class GeminiModule {}
|
||||
240
src/modules/gemini/gemini.service.ts
Normal file
240
src/modules/gemini/gemini.service.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
|
||||
export interface GeminiGenerateOptions {
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export interface GeminiChatMessage {
|
||||
role: 'user' | 'model';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini AI Service
|
||||
*
|
||||
* Provides AI-powered text generation using Google Gemini API.
|
||||
* This service is globally available when ENABLE_GEMINI=true.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple text generation
|
||||
* const response = await geminiService.generateText('Write a poem about coding');
|
||||
*
|
||||
* // With options
|
||||
* const response = await geminiService.generateText('Translate to Turkish', {
|
||||
* temperature: 0.7,
|
||||
* systemPrompt: 'You are a professional translator',
|
||||
* });
|
||||
*
|
||||
* // Chat conversation
|
||||
* const messages = [
|
||||
* { role: 'user', content: 'Hello!' },
|
||||
* { role: 'model', content: 'Hi there!' },
|
||||
* { role: 'user', content: 'What is 2+2?' },
|
||||
* ];
|
||||
* const response = await geminiService.chat(messages);
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class GeminiService implements OnModuleInit {
|
||||
private readonly logger = new Logger(GeminiService.name);
|
||||
private client: GoogleGenAI | null = null;
|
||||
private isEnabled = false;
|
||||
private defaultModel: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.isEnabled = this.configService.get<boolean>('gemini.enabled', false);
|
||||
this.defaultModel = this.configService.get<string>(
|
||||
'gemini.defaultModel',
|
||||
'gemini-2.5-flash',
|
||||
);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.isEnabled) {
|
||||
this.logger.log(
|
||||
'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = this.configService.get<string>('gemini.apiKey');
|
||||
if (!apiKey) {
|
||||
this.logger.warn(
|
||||
'GOOGLE_API_KEY is not set. Gemini features will not work.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = new GoogleGenAI({ apiKey });
|
||||
this.logger.log('✅ Gemini AI initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize Gemini AI', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Gemini is available and properly configured
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.isEnabled && this.client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text content from a prompt
|
||||
*
|
||||
* @param prompt - The text prompt to send to the AI
|
||||
* @param options - Optional configuration for the generation
|
||||
* @returns Generated text response
|
||||
*/
|
||||
async generateText(
|
||||
prompt: string,
|
||||
options: GeminiGenerateOptions = {},
|
||||
): Promise<{ text: string; usage?: any }> {
|
||||
if (!this.isAvailable()) {
|
||||
throw new Error('Gemini AI is not available. Check your configuration.');
|
||||
}
|
||||
|
||||
const model = options.model || this.defaultModel;
|
||||
|
||||
try {
|
||||
const contents: any[] = [];
|
||||
|
||||
// Add system prompt if provided
|
||||
if (options.systemPrompt) {
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts: [{ text: options.systemPrompt }],
|
||||
});
|
||||
contents.push({
|
||||
role: 'model',
|
||||
parts: [{ text: 'Understood. I will follow these instructions.' }],
|
||||
});
|
||||
}
|
||||
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
});
|
||||
|
||||
const response = await this.client!.models.generateContent({
|
||||
model,
|
||||
contents,
|
||||
config: {
|
||||
temperature: options.temperature,
|
||||
maxOutputTokens: options.maxTokens,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
text: (response.text || '').trim(),
|
||||
usage: response.usageMetadata,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Gemini generation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Have a multi-turn chat conversation
|
||||
*
|
||||
* @param messages - Array of chat messages
|
||||
* @param options - Optional configuration for the generation
|
||||
* @returns Generated text response
|
||||
*/
|
||||
async chat(
|
||||
messages: GeminiChatMessage[],
|
||||
options: GeminiGenerateOptions = {},
|
||||
): Promise<{ text: string; usage?: any }> {
|
||||
if (!this.isAvailable()) {
|
||||
throw new Error('Gemini AI is not available. Check your configuration.');
|
||||
}
|
||||
|
||||
const model = options.model || this.defaultModel;
|
||||
|
||||
try {
|
||||
const contents = messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
parts: [{ text: msg.content }],
|
||||
}));
|
||||
|
||||
// Prepend system prompt if provided
|
||||
if (options.systemPrompt) {
|
||||
contents.unshift(
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: options.systemPrompt }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Understood. I will follow these instructions.' }],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.client!.models.generateContent({
|
||||
model,
|
||||
contents,
|
||||
config: {
|
||||
temperature: options.temperature,
|
||||
maxOutputTokens: options.maxTokens,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
text: (response.text || '').trim(),
|
||||
usage: response.usageMetadata,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Gemini chat failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate structured JSON output
|
||||
*
|
||||
* @param prompt - The prompt describing what JSON to generate
|
||||
* @param schema - JSON schema description for the expected output
|
||||
* @param options - Optional configuration for the generation
|
||||
* @returns Parsed JSON object
|
||||
*/
|
||||
async generateJSON<T = any>(
|
||||
prompt: string,
|
||||
schema: string,
|
||||
options: GeminiGenerateOptions = {},
|
||||
): Promise<{ data: T; usage?: any }> {
|
||||
const fullPrompt = `${prompt}
|
||||
|
||||
Output the result as valid JSON that matches this schema:
|
||||
${schema}
|
||||
|
||||
IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
|
||||
const response = await this.generateText(fullPrompt, options);
|
||||
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
let jsonStr = response.text;
|
||||
|
||||
// Remove potential markdown code blocks
|
||||
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (jsonMatch) {
|
||||
jsonStr = jsonMatch[1].trim();
|
||||
}
|
||||
|
||||
const data = JSON.parse(jsonStr) as T;
|
||||
return { data, usage: response.usage };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse JSON response', error);
|
||||
throw new Error('Failed to parse AI response as JSON');
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/modules/gemini/index.ts
Normal file
3
src/modules/gemini/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './gemini.module';
|
||||
export * from './gemini.service';
|
||||
export * from './gemini.config';
|
||||
44
src/modules/health/health.controller.ts
Normal file
44
src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckService,
|
||||
PrismaHealthIndicator,
|
||||
} from '@nestjs/terminus';
|
||||
import { Public } from '../../common/decorators';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private health: HealthCheckService,
|
||||
private prismaHealth: PrismaHealthIndicator,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@HealthCheck()
|
||||
@ApiOperation({ summary: 'Basic health check' })
|
||||
check() {
|
||||
return this.health.check([]);
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@Public()
|
||||
@HealthCheck()
|
||||
@ApiOperation({ summary: 'Readiness check (includes database)' })
|
||||
readiness() {
|
||||
return this.health.check([
|
||||
() => this.prismaHealth.pingCheck('database', this.prisma),
|
||||
]);
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Liveness check' })
|
||||
liveness() {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
11
src/modules/health/health.module.ts
Normal file
11
src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { PrismaHealthIndicator } from '@nestjs/terminus';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
controllers: [HealthController],
|
||||
providers: [PrismaHealthIndicator],
|
||||
})
|
||||
export class HealthModule {}
|
||||
77
src/modules/users/dto/user.dto.ts
Normal file
77
src/modules/users/dto/user.dto.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiPropertyOptional({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'password123', minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'John' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {
|
||||
@ApiPropertyOptional({ example: 'John' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
import { Exclude, Expose } from 'class-transformer';
|
||||
|
||||
@Exclude()
|
||||
export class UserResponseDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
email: string;
|
||||
|
||||
@Expose()
|
||||
firstName: string | null;
|
||||
|
||||
@Expose()
|
||||
lastName: string | null;
|
||||
|
||||
@Expose()
|
||||
isActive: boolean;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
}
|
||||
65
src/modules/users/users.controller.ts
Normal file
65
src/modules/users/users.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BaseController } from '../../common/base';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
||||
import { CurrentUser, Roles } from '../../common/decorators';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
} from '../../common/types/api-response.type';
|
||||
import { User } from '@prisma/client/wasm';
|
||||
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { UserResponseDto } from './dto/user.dto';
|
||||
|
||||
interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@ApiTags('Users')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
export class UsersController extends BaseController<
|
||||
User,
|
||||
CreateUserDto,
|
||||
UpdateUserDto
|
||||
> {
|
||||
constructor(private readonly usersService: UsersService) {
|
||||
super(usersService, 'User');
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
async getMe(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
const fullUser = await this.usersService.findOneWithRoles(user.id);
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, fullUser),
|
||||
'User profile retrieved successfully',
|
||||
);
|
||||
}
|
||||
|
||||
// Override create to require admin role
|
||||
@Roles('admin')
|
||||
async create(
|
||||
...args: Parameters<
|
||||
BaseController<User, CreateUserDto, UpdateUserDto>['create']
|
||||
>
|
||||
) {
|
||||
return super.create(...args);
|
||||
}
|
||||
|
||||
// Override delete to require admin role
|
||||
@Roles('admin')
|
||||
async delete(
|
||||
...args: Parameters<
|
||||
BaseController<User, CreateUserDto, UpdateUserDto>['delete']
|
||||
>
|
||||
) {
|
||||
return super.delete(...args);
|
||||
}
|
||||
}
|
||||
10
src/modules/users/users.module.ts
Normal file
10
src/modules/users/users.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
110
src/modules/users/users.service.ts
Normal file
110
src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Injectable, ConflictException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { BaseService } from '../../common/base';
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
||||
import { User } from '@prisma/client/wasm';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService extends BaseService<
|
||||
User,
|
||||
CreateUserDto,
|
||||
UpdateUserDto
|
||||
> {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'User');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user with hashed password
|
||||
*/
|
||||
async create(dto: CreateUserDto): Promise<User> {
|
||||
// Check if email already exists
|
||||
const existingUser = await this.findOneBy({ email: dto.email });
|
||||
if (existingUser) {
|
||||
throw new ConflictException('EMAIL_ALREADY_EXISTS');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await this.hashPassword(dto.password);
|
||||
|
||||
return super.create({
|
||||
...dto,
|
||||
password: hashedPassword,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user, hash password if provided
|
||||
*/
|
||||
async update(id: string, dto: UpdateUserDto): Promise<User> {
|
||||
if (dto.password) {
|
||||
dto.password = await this.hashPassword(dto.password);
|
||||
}
|
||||
|
||||
return super.update(id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.findOneBy({ email });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user with roles and permissions
|
||||
*/
|
||||
findOneWithRoles(id: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign role to user
|
||||
*/
|
||||
assignRole(userId: string, roleId: string) {
|
||||
return this.prisma.userRole.create({
|
||||
data: {
|
||||
userId,
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove role from user
|
||||
*/
|
||||
removeRole(userId: string, roleId: string) {
|
||||
return this.prisma.userRole.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 12;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user