Initial commit

This commit is contained in:
2026-03-28 17:16:12 +03:00
commit 829413f05d
113 changed files with 23525 additions and 0 deletions

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