generated from fahricansecer/boilerplate-be
Initial commit
This commit is contained in:
270
src/modules/admin/admin.controller.ts
Normal file
270
src/modules/admin/admin.controller.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CacheInterceptor,
|
||||
CacheKey,
|
||||
CacheTTL,
|
||||
CACHE_MANAGER,
|
||||
} from '@nestjs/cache-manager';
|
||||
import * as cacheManager from 'cache-manager';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { Roles } from '../../common/decorators';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
createPaginatedResponse,
|
||||
PaginatedData,
|
||||
} from '../../common/types/api-response.type';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { UserResponseDto } from '../users/dto/user.dto';
|
||||
import {
|
||||
PermissionResponseDto,
|
||||
RolePermissionResponseDto,
|
||||
RoleResponseDto,
|
||||
UserRoleResponseDto,
|
||||
} from './dto/admin.dto';
|
||||
|
||||
@ApiTags('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Controller('admin')
|
||||
@Roles('admin')
|
||||
export class AdminController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache,
|
||||
) {}
|
||||
|
||||
// ================== Users Management ==================
|
||||
|
||||
@Get('users')
|
||||
@ApiOperation({ summary: 'Get all users (admin)' })
|
||||
async getAllUsers(
|
||||
@Query() pagination: PaginationDto,
|
||||
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
|
||||
const { skip, take, orderBy } = pagination;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
skip,
|
||||
take,
|
||||
orderBy,
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count(),
|
||||
]);
|
||||
|
||||
const dtos = plainToInstance(
|
||||
UserResponseDto,
|
||||
users,
|
||||
) as unknown as UserResponseDto[];
|
||||
|
||||
return createPaginatedResponse(
|
||||
dtos,
|
||||
total,
|
||||
pagination.page || 1,
|
||||
pagination.limit || 10,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('users/:id/toggle-active')
|
||||
@ApiOperation({ summary: 'Toggle user active status' })
|
||||
async toggleUserActive(
|
||||
@Param('id') id: string,
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { isActive: !user?.isActive },
|
||||
});
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, updated),
|
||||
'User status updated',
|
||||
);
|
||||
}
|
||||
|
||||
@Post('users/:userId/roles/:roleId')
|
||||
@ApiOperation({ summary: 'Assign role to user' })
|
||||
async assignRole(
|
||||
@Param('userId') userId: string,
|
||||
@Param('roleId') roleId: string,
|
||||
): Promise<ApiResponse<UserRoleResponseDto>> {
|
||||
const userRole = await this.prisma.userRole.create({
|
||||
data: { userId, roleId },
|
||||
});
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserRoleResponseDto, userRole),
|
||||
'Role assigned to user',
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('users/:userId/roles/:roleId')
|
||||
@ApiOperation({ summary: 'Remove role from user' })
|
||||
async removeRole(
|
||||
@Param('userId') userId: string,
|
||||
@Param('roleId') roleId: string,
|
||||
): Promise<ApiResponse<null>> {
|
||||
await this.prisma.userRole.deleteMany({
|
||||
where: { userId, roleId },
|
||||
});
|
||||
return createSuccessResponse(null, 'Role removed from user');
|
||||
}
|
||||
|
||||
// ================== Roles Management ==================
|
||||
|
||||
@Get('roles')
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheKey('roles_list')
|
||||
@CacheTTL(60 * 1000)
|
||||
@ApiOperation({ summary: 'Get all roles' })
|
||||
async getAllRoles(): Promise<ApiResponse<RoleResponseDto[]>> {
|
||||
const roles = await this.prisma.role.findMany({
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { users: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Transform Prisma structure to DTO structure
|
||||
const transformedRoles = roles.map((role) => ({
|
||||
...role,
|
||||
permissions: role.permissions.map((rp) => rp.permission),
|
||||
}));
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(
|
||||
RoleResponseDto,
|
||||
transformedRoles,
|
||||
) as unknown as RoleResponseDto[],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('roles')
|
||||
@ApiOperation({ summary: 'Create a new role' })
|
||||
async createRole(
|
||||
@Body() data: { name: string; description?: string },
|
||||
): Promise<ApiResponse<RoleResponseDto>> {
|
||||
const role = await this.prisma.role.create({ data });
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(RoleResponseDto, role),
|
||||
'Role created',
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('roles/:id')
|
||||
@ApiOperation({ summary: 'Update a role' })
|
||||
async updateRole(
|
||||
@Param('id') id: string,
|
||||
@Body() data: { name?: string; description?: string },
|
||||
): Promise<ApiResponse<RoleResponseDto>> {
|
||||
const role = await this.prisma.role.update({ where: { id }, data });
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(RoleResponseDto, role),
|
||||
'Role updated',
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('roles/:id')
|
||||
@ApiOperation({ summary: 'Delete a role' })
|
||||
async deleteRole(@Param('id') id: string): Promise<ApiResponse<null>> {
|
||||
await this.prisma.role.delete({ where: { id } });
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(null, 'Role deleted');
|
||||
}
|
||||
|
||||
// ================== Permissions Management ==================
|
||||
|
||||
@Get('permissions')
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheKey('permissions_list')
|
||||
@CacheTTL(60 * 1000)
|
||||
@ApiOperation({ summary: 'Get all permissions' })
|
||||
async getAllPermissions(): Promise<ApiResponse<PermissionResponseDto[]>> {
|
||||
const permissions = await this.prisma.permission.findMany();
|
||||
return createSuccessResponse(
|
||||
plainToInstance(
|
||||
PermissionResponseDto,
|
||||
permissions,
|
||||
) as unknown as PermissionResponseDto[],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('permissions')
|
||||
@ApiOperation({ summary: 'Create a new permission' })
|
||||
async createPermission(
|
||||
@Body()
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
},
|
||||
): Promise<ApiResponse<PermissionResponseDto>> {
|
||||
const permission = await this.prisma.permission.create({ data });
|
||||
await this.cacheManager.del('permissions_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(PermissionResponseDto, permission),
|
||||
'Permission created',
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('roles/:roleId/permissions/:permissionId')
|
||||
@ApiOperation({ summary: 'Assign permission to role' })
|
||||
async assignPermission(
|
||||
@Param('roleId') roleId: string,
|
||||
@Param('permissionId') permissionId: string,
|
||||
): Promise<ApiResponse<RolePermissionResponseDto>> {
|
||||
const rolePermission = await this.prisma.rolePermission.create({
|
||||
data: { roleId, permissionId },
|
||||
});
|
||||
// Invalidate roles_list because permissions are nested in roles
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(RolePermissionResponseDto, rolePermission),
|
||||
'Permission assigned to role',
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('roles/:roleId/permissions/:permissionId')
|
||||
@ApiOperation({ summary: 'Remove permission from role' })
|
||||
async removePermission(
|
||||
@Param('roleId') roleId: string,
|
||||
@Param('permissionId') permissionId: string,
|
||||
): Promise<ApiResponse<null>> {
|
||||
await this.prisma.rolePermission.deleteMany({
|
||||
where: { roleId, permissionId },
|
||||
});
|
||||
// Invalidate roles_list because permissions are nested in roles
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(null, 'Permission removed from role');
|
||||
}
|
||||
}
|
||||
7
src/modules/admin/admin.module.ts
Normal file
7
src/modules/admin/admin.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
71
src/modules/admin/dto/admin.dto.ts
Normal file
71
src/modules/admin/dto/admin.dto.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Exclude, Expose, Type } from 'class-transformer';
|
||||
|
||||
@Exclude()
|
||||
export class PermissionResponseDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
description: string | null;
|
||||
|
||||
@Expose()
|
||||
resource: string;
|
||||
|
||||
@Expose()
|
||||
action: string;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
export class RoleResponseDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
description: string | null;
|
||||
|
||||
@Expose()
|
||||
@Type(() => PermissionResponseDto)
|
||||
permissions?: PermissionResponseDto[];
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
export class UserRoleResponseDto {
|
||||
@Expose()
|
||||
userId: string;
|
||||
|
||||
@Expose()
|
||||
roleId: string;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
export class RolePermissionResponseDto {
|
||||
@Expose()
|
||||
roleId: string;
|
||||
|
||||
@Expose()
|
||||
permissionId: string;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
}
|
||||
78
src/modules/auth/auth.controller.ts
Normal file
78
src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
|
||||
import { I18n, I18nContext } from 'nestjs-i18n';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
RegisterDto,
|
||||
LoginDto,
|
||||
RefreshTokenDto,
|
||||
TokenResponseDto,
|
||||
} from './dto/auth.dto';
|
||||
import { Public } from '../../common/decorators';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
} from '../../common/types/api-response.type';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@ApiOkResponse({
|
||||
description: 'User registered successfully',
|
||||
type: TokenResponseDto,
|
||||
})
|
||||
async register(
|
||||
@Body() dto: RegisterDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<TokenResponseDto>> {
|
||||
const result = await this.authService.register(dto);
|
||||
return createSuccessResponse(result, i18n.t('auth.registered'), 201);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@ApiOkResponse({ description: 'Login successful', type: TokenResponseDto })
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<TokenResponseDto>> {
|
||||
const result = await this.authService.login(dto);
|
||||
return createSuccessResponse(result, i18n.t('auth.login_success'));
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
@ApiOkResponse({
|
||||
description: 'Token refreshed successfully',
|
||||
type: TokenResponseDto,
|
||||
})
|
||||
async refreshToken(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<TokenResponseDto>> {
|
||||
const result = await this.authService.refreshToken(dto.refreshToken);
|
||||
return createSuccessResponse(result, i18n.t('auth.refresh_success'));
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Logout and invalidate refresh token' })
|
||||
@ApiOkResponse({ description: 'Logout successful' })
|
||||
async logout(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<null>> {
|
||||
await this.authService.logout(dto.refreshToken);
|
||||
return createSuccessResponse(null, i18n.t('auth.logout_success'));
|
||||
}
|
||||
}
|
||||
37
src/modules/auth/auth.module.ts
Normal file
37
src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { JwtAuthGuard, RolesGuard, PermissionsGuard } from './guards';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService): JwtModuleOptions => {
|
||||
const expiresIn =
|
||||
configService.get<string>('JWT_ACCESS_EXPIRATION') || '15m';
|
||||
return {
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: expiresIn as any,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
JwtAuthGuard,
|
||||
RolesGuard,
|
||||
PermissionsGuard,
|
||||
],
|
||||
exports: [AuthService, JwtAuthGuard, RolesGuard, PermissionsGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
336
src/modules/auth/auth.service.ts
Normal file
336
src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { RegisterDto, LoginDto, TokenResponseDto } from './dto/auth.dto';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
interface UserWithRoles {
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
isActive: boolean;
|
||||
tenantId: string | null;
|
||||
roles: Array<{
|
||||
role: {
|
||||
name: string;
|
||||
permissions: Array<{
|
||||
permission: {
|
||||
name: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
async register(dto: RegisterDto): Promise<TokenResponseDto> {
|
||||
// Check if email already exists
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('EMAIL_ALREADY_EXISTS');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await this.hashPassword(dto.password);
|
||||
|
||||
// Create user with default role
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
password: hashedPassword,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
roles: {
|
||||
create: {
|
||||
role: {
|
||||
connectOrCreate: {
|
||||
where: { name: 'user' },
|
||||
create: { name: 'user', description: 'Default user role' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.generateTokens(user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
async login(dto: LoginDto): Promise<TokenResponseDto> {
|
||||
// Find user by email
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await this.comparePassword(
|
||||
dto.password,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('ACCOUNT_DISABLED');
|
||||
}
|
||||
|
||||
return this.generateTokens(user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<TokenResponseDto> {
|
||||
// Find refresh token
|
||||
const storedToken = await this.prisma.refreshToken.findUnique({
|
||||
where: { token: refreshToken },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!storedToken) {
|
||||
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
if (storedToken.expiresAt < new Date()) {
|
||||
// Delete expired token
|
||||
await this.prisma.refreshToken.delete({
|
||||
where: { id: storedToken.id },
|
||||
});
|
||||
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
// Delete old refresh token
|
||||
await this.prisma.refreshToken.delete({
|
||||
where: { id: storedToken.id },
|
||||
});
|
||||
|
||||
return this.generateTokens(storedToken.user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - invalidate refresh token
|
||||
*/
|
||||
async logout(refreshToken: string): Promise<void> {
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
where: { token: refreshToken },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user by ID (used by JWT strategy)
|
||||
*/
|
||||
async validateUser(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove password from user object
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { password: _, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access and refresh tokens
|
||||
*/
|
||||
private async generateTokens(user: UserWithRoles): Promise<TokenResponseDto> {
|
||||
// Extract roles and permissions
|
||||
const roles = user.roles.map((ur) => ur.role.name);
|
||||
const permissions = user.roles.flatMap((ur) =>
|
||||
ur.role.permissions.map((rp) => rp.permission.name),
|
||||
);
|
||||
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
roles,
|
||||
permissions,
|
||||
tenantId: user.tenantId || undefined,
|
||||
};
|
||||
|
||||
// Generate access token
|
||||
const accessToken = this.jwtService.sign(payload, {
|
||||
expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
||||
});
|
||||
|
||||
// Generate refresh token
|
||||
const refreshTokenValue = crypto.randomUUID();
|
||||
const refreshExpiration = this.parseExpiration(
|
||||
this.configService.get('JWT_REFRESH_EXPIRATION', '7d'),
|
||||
);
|
||||
|
||||
// Store refresh token
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
token: refreshTokenValue,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + refreshExpiration),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: refreshTokenValue,
|
||||
expiresIn:
|
||||
this.parseExpiration(
|
||||
this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
||||
) / 1000, // Convert to seconds
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName || undefined,
|
||||
lastName: user.lastName || undefined,
|
||||
roles,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 12;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare password with hash
|
||||
*/
|
||||
private async comparePassword(
|
||||
password: string,
|
||||
hashedPassword: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hashedPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse expiration string to milliseconds
|
||||
*/
|
||||
private parseExpiration(expiration: string): number {
|
||||
const match = expiration.match(/^(\d+)([smhd])$/);
|
||||
if (!match) {
|
||||
return 15 * 60 * 1000; // Default 15 minutes
|
||||
}
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case 's':
|
||||
return value * 1000;
|
||||
case 'm':
|
||||
return value * 60 * 1000;
|
||||
case 'h':
|
||||
return value * 60 * 60 * 1000;
|
||||
case 'd':
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 15 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/modules/auth/dto/auth.dto.ts
Normal file
70
src/modules/auth/dto/auth.dto.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'password123', minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'John' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'password123' })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export class UserInfoDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
firstName?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
lastName?: string;
|
||||
|
||||
@ApiProperty()
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export class TokenResponseDto {
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
expiresIn: number;
|
||||
|
||||
@ApiProperty({ type: UserInfoDto })
|
||||
user: UserInfoDto;
|
||||
}
|
||||
129
src/modules/auth/guards/auth.guards.ts
Normal file
129
src/modules/auth/guards/auth.guards.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request } from 'express';
|
||||
import {
|
||||
IS_PUBLIC_KEY,
|
||||
ROLES_KEY,
|
||||
PERMISSIONS_KEY,
|
||||
} from '../../../common/decorators';
|
||||
|
||||
interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Auth Guard - Validates JWT token
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
// Check if route is public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest<TUser = AuthenticatedUser>(
|
||||
err: Error | null,
|
||||
user: TUser | false,
|
||||
info: any,
|
||||
): TUser {
|
||||
if (err || !user) {
|
||||
if (info?.name === 'TokenExpiredError') {
|
||||
throw new UnauthorizedException('TOKEN_EXPIRED');
|
||||
}
|
||||
throw err || new UnauthorizedException('AUTH_REQUIRED');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles Guard - Check if user has required roles
|
||||
*/
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as AuthenticatedUser | undefined;
|
||||
|
||||
if (!user || !user.roles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('PERMISSION_DENIED');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions Guard - Check if user has required permissions
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
||||
PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as AuthenticatedUser | undefined;
|
||||
|
||||
if (!user || !user.permissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasPermission = requiredPermissions.every((permission) =>
|
||||
user.permissions.includes(permission),
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new ForbiddenException('PERMISSION_DENIED');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
1
src/modules/auth/guards/index.ts
Normal file
1
src/modules/auth/guards/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './auth.guards';
|
||||
38
src/modules/auth/strategies/jwt.strategy.ts
Normal file
38
src/modules/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService, JwtPayload } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
const secret = configService.get<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
}
|
||||
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: secret,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.authService.validateUser(payload.sub);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
roles: payload.roles,
|
||||
permissions: payload.permissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
7
src/modules/gemini/gemini.config.ts
Normal file
7
src/modules/gemini/gemini.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export const geminiConfig = registerAs('gemini', () => ({
|
||||
enabled: process.env.ENABLE_GEMINI === 'true',
|
||||
apiKey: process.env.GOOGLE_API_KEY,
|
||||
defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
|
||||
}));
|
||||
18
src/modules/gemini/gemini.module.ts
Normal file
18
src/modules/gemini/gemini.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { GeminiService } from './gemini.service';
|
||||
import { geminiConfig } from './gemini.config';
|
||||
|
||||
/**
|
||||
* Gemini AI Module
|
||||
*
|
||||
* Optional module for AI-powered features using Google Gemini API.
|
||||
* Enable by setting ENABLE_GEMINI=true in your .env file.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule.forFeature(geminiConfig)],
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
})
|
||||
export class GeminiModule {}
|
||||
240
src/modules/gemini/gemini.service.ts
Normal file
240
src/modules/gemini/gemini.service.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
|
||||
export interface GeminiGenerateOptions {
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export interface GeminiChatMessage {
|
||||
role: 'user' | 'model';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini AI Service
|
||||
*
|
||||
* Provides AI-powered text generation using Google Gemini API.
|
||||
* This service is globally available when ENABLE_GEMINI=true.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple text generation
|
||||
* const response = await geminiService.generateText('Write a poem about coding');
|
||||
*
|
||||
* // With options
|
||||
* const response = await geminiService.generateText('Translate to Turkish', {
|
||||
* temperature: 0.7,
|
||||
* systemPrompt: 'You are a professional translator',
|
||||
* });
|
||||
*
|
||||
* // Chat conversation
|
||||
* const messages = [
|
||||
* { role: 'user', content: 'Hello!' },
|
||||
* { role: 'model', content: 'Hi there!' },
|
||||
* { role: 'user', content: 'What is 2+2?' },
|
||||
* ];
|
||||
* const response = await geminiService.chat(messages);
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class GeminiService implements OnModuleInit {
|
||||
private readonly logger = new Logger(GeminiService.name);
|
||||
private client: GoogleGenAI | null = null;
|
||||
private isEnabled = false;
|
||||
private defaultModel: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.isEnabled = this.configService.get<boolean>('gemini.enabled', false);
|
||||
this.defaultModel = this.configService.get<string>(
|
||||
'gemini.defaultModel',
|
||||
'gemini-2.5-flash',
|
||||
);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.isEnabled) {
|
||||
this.logger.log(
|
||||
'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = this.configService.get<string>('gemini.apiKey');
|
||||
if (!apiKey) {
|
||||
this.logger.warn(
|
||||
'GOOGLE_API_KEY is not set. Gemini features will not work.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = new GoogleGenAI({ apiKey });
|
||||
this.logger.log('✅ Gemini AI initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize Gemini AI', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Gemini is available and properly configured
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.isEnabled && this.client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text content from a prompt
|
||||
*
|
||||
* @param prompt - The text prompt to send to the AI
|
||||
* @param options - Optional configuration for the generation
|
||||
* @returns Generated text response
|
||||
*/
|
||||
async generateText(
|
||||
prompt: string,
|
||||
options: GeminiGenerateOptions = {},
|
||||
): Promise<{ text: string; usage?: any }> {
|
||||
if (!this.isAvailable()) {
|
||||
throw new Error('Gemini AI is not available. Check your configuration.');
|
||||
}
|
||||
|
||||
const model = options.model || this.defaultModel;
|
||||
|
||||
try {
|
||||
const contents: any[] = [];
|
||||
|
||||
// Add system prompt if provided
|
||||
if (options.systemPrompt) {
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts: [{ text: options.systemPrompt }],
|
||||
});
|
||||
contents.push({
|
||||
role: 'model',
|
||||
parts: [{ text: 'Understood. I will follow these instructions.' }],
|
||||
});
|
||||
}
|
||||
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
});
|
||||
|
||||
const response = await this.client!.models.generateContent({
|
||||
model,
|
||||
contents,
|
||||
config: {
|
||||
temperature: options.temperature,
|
||||
maxOutputTokens: options.maxTokens,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
text: (response.text || '').trim(),
|
||||
usage: response.usageMetadata,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Gemini generation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Have a multi-turn chat conversation
|
||||
*
|
||||
* @param messages - Array of chat messages
|
||||
* @param options - Optional configuration for the generation
|
||||
* @returns Generated text response
|
||||
*/
|
||||
async chat(
|
||||
messages: GeminiChatMessage[],
|
||||
options: GeminiGenerateOptions = {},
|
||||
): Promise<{ text: string; usage?: any }> {
|
||||
if (!this.isAvailable()) {
|
||||
throw new Error('Gemini AI is not available. Check your configuration.');
|
||||
}
|
||||
|
||||
const model = options.model || this.defaultModel;
|
||||
|
||||
try {
|
||||
const contents = messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
parts: [{ text: msg.content }],
|
||||
}));
|
||||
|
||||
// Prepend system prompt if provided
|
||||
if (options.systemPrompt) {
|
||||
contents.unshift(
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: options.systemPrompt }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Understood. I will follow these instructions.' }],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.client!.models.generateContent({
|
||||
model,
|
||||
contents,
|
||||
config: {
|
||||
temperature: options.temperature,
|
||||
maxOutputTokens: options.maxTokens,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
text: (response.text || '').trim(),
|
||||
usage: response.usageMetadata,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Gemini chat failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate structured JSON output
|
||||
*
|
||||
* @param prompt - The prompt describing what JSON to generate
|
||||
* @param schema - JSON schema description for the expected output
|
||||
* @param options - Optional configuration for the generation
|
||||
* @returns Parsed JSON object
|
||||
*/
|
||||
async generateJSON<T = any>(
|
||||
prompt: string,
|
||||
schema: string,
|
||||
options: GeminiGenerateOptions = {},
|
||||
): Promise<{ data: T; usage?: any }> {
|
||||
const fullPrompt = `${prompt}
|
||||
|
||||
Output the result as valid JSON that matches this schema:
|
||||
${schema}
|
||||
|
||||
IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
|
||||
const response = await this.generateText(fullPrompt, options);
|
||||
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
let jsonStr = response.text;
|
||||
|
||||
// Remove potential markdown code blocks
|
||||
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (jsonMatch) {
|
||||
jsonStr = jsonMatch[1].trim();
|
||||
}
|
||||
|
||||
const data = JSON.parse(jsonStr) as T;
|
||||
return { data, usage: response.usage };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse JSON response', error);
|
||||
throw new Error('Failed to parse AI response as JSON');
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/modules/gemini/index.ts
Normal file
3
src/modules/gemini/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './gemini.module';
|
||||
export * from './gemini.service';
|
||||
export * from './gemini.config';
|
||||
44
src/modules/health/health.controller.ts
Normal file
44
src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckService,
|
||||
PrismaHealthIndicator,
|
||||
} from '@nestjs/terminus';
|
||||
import { Public } from '../../common/decorators';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private health: HealthCheckService,
|
||||
private prismaHealth: PrismaHealthIndicator,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@HealthCheck()
|
||||
@ApiOperation({ summary: 'Basic health check' })
|
||||
check() {
|
||||
return this.health.check([]);
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@Public()
|
||||
@HealthCheck()
|
||||
@ApiOperation({ summary: 'Readiness check (includes database)' })
|
||||
readiness() {
|
||||
return this.health.check([
|
||||
() => this.prismaHealth.pingCheck('database', this.prisma),
|
||||
]);
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Liveness check' })
|
||||
liveness() {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
11
src/modules/health/health.module.ts
Normal file
11
src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { PrismaHealthIndicator } from '@nestjs/terminus';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
controllers: [HealthController],
|
||||
providers: [PrismaHealthIndicator],
|
||||
})
|
||||
export class HealthModule {}
|
||||
77
src/modules/users/dto/user.dto.ts
Normal file
77
src/modules/users/dto/user.dto.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiPropertyOptional({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'password123', minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'John' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {
|
||||
@ApiPropertyOptional({ example: 'John' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
import { Exclude, Expose } from 'class-transformer';
|
||||
|
||||
@Exclude()
|
||||
export class UserResponseDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
email: string;
|
||||
|
||||
@Expose()
|
||||
firstName: string | null;
|
||||
|
||||
@Expose()
|
||||
lastName: string | null;
|
||||
|
||||
@Expose()
|
||||
isActive: boolean;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
}
|
||||
65
src/modules/users/users.controller.ts
Normal file
65
src/modules/users/users.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BaseController } from '../../common/base';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
||||
import { CurrentUser, Roles } from '../../common/decorators';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
} from '../../common/types/api-response.type';
|
||||
import { User } from '@prisma/client/wasm';
|
||||
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { UserResponseDto } from './dto/user.dto';
|
||||
|
||||
interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@ApiTags('Users')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
export class UsersController extends BaseController<
|
||||
User,
|
||||
CreateUserDto,
|
||||
UpdateUserDto
|
||||
> {
|
||||
constructor(private readonly usersService: UsersService) {
|
||||
super(usersService, 'User');
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
async getMe(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
const fullUser = await this.usersService.findOneWithRoles(user.id);
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, fullUser),
|
||||
'User profile retrieved successfully',
|
||||
);
|
||||
}
|
||||
|
||||
// Override create to require admin role
|
||||
@Roles('admin')
|
||||
async create(
|
||||
...args: Parameters<
|
||||
BaseController<User, CreateUserDto, UpdateUserDto>['create']
|
||||
>
|
||||
) {
|
||||
return super.create(...args);
|
||||
}
|
||||
|
||||
// Override delete to require admin role
|
||||
@Roles('admin')
|
||||
async delete(
|
||||
...args: Parameters<
|
||||
BaseController<User, CreateUserDto, UpdateUserDto>['delete']
|
||||
>
|
||||
) {
|
||||
return super.delete(...args);
|
||||
}
|
||||
}
|
||||
10
src/modules/users/users.module.ts
Normal file
10
src/modules/users/users.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
110
src/modules/users/users.service.ts
Normal file
110
src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Injectable, ConflictException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { BaseService } from '../../common/base';
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
||||
import { User } from '@prisma/client/wasm';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService extends BaseService<
|
||||
User,
|
||||
CreateUserDto,
|
||||
UpdateUserDto
|
||||
> {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'User');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user with hashed password
|
||||
*/
|
||||
async create(dto: CreateUserDto): Promise<User> {
|
||||
// Check if email already exists
|
||||
const existingUser = await this.findOneBy({ email: dto.email });
|
||||
if (existingUser) {
|
||||
throw new ConflictException('EMAIL_ALREADY_EXISTS');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await this.hashPassword(dto.password);
|
||||
|
||||
return super.create({
|
||||
...dto,
|
||||
password: hashedPassword,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user, hash password if provided
|
||||
*/
|
||||
async update(id: string, dto: UpdateUserDto): Promise<User> {
|
||||
if (dto.password) {
|
||||
dto.password = await this.hashPassword(dto.password);
|
||||
}
|
||||
|
||||
return super.update(id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.findOneBy({ email });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user with roles and permissions
|
||||
*/
|
||||
findOneWithRoles(id: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign role to user
|
||||
*/
|
||||
assignRole(userId: string, roleId: string) {
|
||||
return this.prisma.userRole.create({
|
||||
data: {
|
||||
userId,
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove role from user
|
||||
*/
|
||||
removeRole(userId: string, roleId: string) {
|
||||
return this.prisma.userRole.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 12;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user