main
Some checks failed
CI / build (push) Failing after 1m58s

This commit is contained in:
2026-01-26 23:22:38 +03:00
commit 6ef44f398d
110 changed files with 23268 additions and 0 deletions

View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View 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`,
);
}
}

View 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
View File

@@ -0,0 +1,2 @@
export * from './base.service';
export * from './base.controller';

View 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';
},
);

View 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' };
}
}

View 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);
}
}

View 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
);
}
}

View 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: [],
};
}

View 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),
}));

View 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;
}

View 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 {}

View 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
View 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
View 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
View 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"
}

View 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
View 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
View 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
View 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"
}

View 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
View 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();

View 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');
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
@Module({
controllers: [AdminController],
})
export class AdminModule {}

View 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;
}

View 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'));
}
}

View 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 {}

View 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;
}
}
}

View 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;
}

View 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;
}
}

View File

@@ -0,0 +1 @@
export * from './auth.guards';

View 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,
};
}
}

View 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',
}));

View 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 {}

View 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');
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './gemini.module';
export * from './gemini.service';
export * from './gemini.config';

View 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() };
}
}

View 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 {}

View 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;
}

View 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);
}
}

View 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 {}

View 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);
}
}