feat: Implement comprehensive user authentication with JWT, social login, and password management.
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
import { Public } from './auth';
|
||||||
|
|
||||||
@ApiTags('Health')
|
@ApiTags('Health')
|
||||||
@Controller()
|
@Controller()
|
||||||
|
@Public()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
|||||||
|
|
||||||
import { configuration } from './config';
|
import { configuration } from './config';
|
||||||
import { PrismaModule } from './prisma';
|
import { PrismaModule } from './prisma';
|
||||||
|
import { AuthModule, JwtAuthGuard, RolesGuard } from './auth';
|
||||||
|
import { UsersModule } from './users';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
@@ -36,10 +38,9 @@ import { AppService } from './app.service';
|
|||||||
// Database
|
// Database
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
|
||||||
// Feature Modules (will be added as we develop them)
|
// Feature Modules
|
||||||
// AuthModule,
|
AuthModule,
|
||||||
// UsersModule,
|
UsersModule,
|
||||||
// AgentsModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -49,6 +50,16 @@ import { AppService } from './app.service';
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: ThrottlerGuard,
|
useClass: ThrottlerGuard,
|
||||||
},
|
},
|
||||||
|
// Global JWT Auth Guard
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtAuthGuard,
|
||||||
|
},
|
||||||
|
// Global Roles Guard
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: RolesGuard,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
308
src/auth/auth.controller.ts
Normal file
308
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import {
|
||||||
|
RegisterDto,
|
||||||
|
LoginDto,
|
||||||
|
SocialAuthDto,
|
||||||
|
ForgotPasswordDto,
|
||||||
|
ResetPasswordDto,
|
||||||
|
RefreshTokenDto,
|
||||||
|
} from './dto';
|
||||||
|
import { JwtAuthGuard } from './guards';
|
||||||
|
import { Public, CurrentUser } from './decorators';
|
||||||
|
|
||||||
|
@ApiTags('Auth')
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REGISTER
|
||||||
|
// ==========================================
|
||||||
|
@Public()
|
||||||
|
@Post('register')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Register a new user' })
|
||||||
|
@ApiBody({ type: RegisterDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: 'User registered successfully',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: 'uuid',
|
||||||
|
email: 'john@example.com',
|
||||||
|
role: 'USER',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
},
|
||||||
|
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email already registered' })
|
||||||
|
async register(@Body() dto: RegisterDto) {
|
||||||
|
return this.authService.register(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LOGIN
|
||||||
|
// ==========================================
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
|
@ApiBody({ type: LoginDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Login successful',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: 'uuid',
|
||||||
|
email: 'john@example.com',
|
||||||
|
role: 'USER',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
avatar: null,
|
||||||
|
},
|
||||||
|
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||||
|
async login(@Body() dto: LoginDto, @Req() req: Request) {
|
||||||
|
const userAgent = req.headers['user-agent'];
|
||||||
|
const ipAddress =
|
||||||
|
req.ip || req.headers['x-forwarded-for']?.toString() || 'unknown';
|
||||||
|
|
||||||
|
return this.authService.login(dto, userAgent, ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// SOCIAL AUTH
|
||||||
|
// ==========================================
|
||||||
|
@Public()
|
||||||
|
@Post('social')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Login or register with social provider' })
|
||||||
|
@ApiBody({ type: SocialAuthDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Social auth successful',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: 'uuid',
|
||||||
|
email: 'john@example.com',
|
||||||
|
role: 'USER',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
avatar: 'https://example.com/avatar.jpg',
|
||||||
|
},
|
||||||
|
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid provider data' })
|
||||||
|
async socialAuth(@Body() dto: SocialAuthDto) {
|
||||||
|
return this.authService.socialAuth(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// FORGOT PASSWORD
|
||||||
|
// ==========================================
|
||||||
|
@Public()
|
||||||
|
@Post('forgot-password')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Request password reset email' })
|
||||||
|
@ApiBody({ type: ForgotPasswordDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Password reset email sent (if email exists)',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: 'If the email exists, a password reset link will be sent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async forgotPassword(@Body() dto: ForgotPasswordDto) {
|
||||||
|
return this.authService.forgotPassword(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// RESET PASSWORD
|
||||||
|
// ==========================================
|
||||||
|
@Public()
|
||||||
|
@Post('reset-password')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Reset password with token' })
|
||||||
|
@ApiBody({ type: ResetPasswordDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Password reset successful',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: 'Password reset successful. Please login with your new password.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid or expired token' })
|
||||||
|
async resetPassword(@Body() dto: ResetPasswordDto) {
|
||||||
|
return this.authService.resetPassword(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REFRESH TOKEN
|
||||||
|
// ==========================================
|
||||||
|
@Public()
|
||||||
|
@Post('refresh')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Refresh access token' })
|
||||||
|
@ApiBody({ type: RefreshTokenDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Tokens refreshed successfully',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
|
||||||
|
async refreshToken(@Body() dto: RefreshTokenDto) {
|
||||||
|
return this.authService.refreshToken(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// GET CURRENT USER
|
||||||
|
// ==========================================
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Current user profile',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: 'uuid',
|
||||||
|
email: 'john@example.com',
|
||||||
|
role: 'USER',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
emailVerified: true,
|
||||||
|
authProvider: 'LOCAL',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
avatar: null,
|
||||||
|
phone: null,
|
||||||
|
city: null,
|
||||||
|
state: null,
|
||||||
|
country: null,
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
lastLoginAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
async getCurrentUser(@CurrentUser('id') userId: string) {
|
||||||
|
return this.authService.getCurrentUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LOGOUT
|
||||||
|
// ==========================================
|
||||||
|
@Post('logout')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Logout current session' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Logged out successfully',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: 'Logged out successfully',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
async logout(@CurrentUser('id') userId: string, @Req() req: Request) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const accessToken = authHeader?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
return this.authService.logout(userId, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LOGOUT ALL SESSIONS
|
||||||
|
// ==========================================
|
||||||
|
@Post('logout-all')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({ summary: 'Logout all sessions' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'All sessions logged out',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: 'Logged out successfully',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
async logoutAll(@CurrentUser('id') userId: string) {
|
||||||
|
return this.authService.logout(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/auth/auth.module.ts
Normal file
33
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtStrategy } from './strategies';
|
||||||
|
import { PrismaModule } from '../prisma';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService): JwtModuleOptions => {
|
||||||
|
const expiresIn = configService.get<string>('jwt.accessExpiration') ?? '15m';
|
||||||
|
return {
|
||||||
|
secret: configService.get<string>('jwt.secret'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
exports: [AuthService, JwtModule, PassportModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
657
src/auth/auth.service.ts
Normal file
657
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import slugify from 'slugify';
|
||||||
|
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
RegisterDto,
|
||||||
|
LoginDto,
|
||||||
|
SocialAuthDto,
|
||||||
|
ForgotPasswordDto,
|
||||||
|
ResetPasswordDto,
|
||||||
|
RefreshTokenDto,
|
||||||
|
} from './dto';
|
||||||
|
import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REGISTER
|
||||||
|
// ==========================================
|
||||||
|
async register(dto: RegisterDto) {
|
||||||
|
// Check if user exists
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { email: dto.email.toLowerCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictException('Email already registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await argon2.hash(dto.password);
|
||||||
|
|
||||||
|
// Determine role
|
||||||
|
const role = dto.role === 'AGENT' ? UserRole.AGENT : UserRole.USER;
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: dto.email.toLowerCase(),
|
||||||
|
password: hashedPassword,
|
||||||
|
role,
|
||||||
|
status: UserStatus.ACTIVE,
|
||||||
|
authProvider: AuthProvider.LOCAL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create profile based on role
|
||||||
|
if (role === UserRole.USER) {
|
||||||
|
await this.prisma.userProfile.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
firstName: dto.firstName,
|
||||||
|
lastName: dto.lastName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (role === UserRole.AGENT) {
|
||||||
|
const slug = await this.generateUniqueSlug(dto.firstName, dto.lastName);
|
||||||
|
await this.prisma.agentProfile.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
slug,
|
||||||
|
firstName: dto.firstName,
|
||||||
|
lastName: dto.lastName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
await this.createSession(user.id, tokens.accessToken, tokens.refreshToken);
|
||||||
|
|
||||||
|
// Emit event for email verification (can be handled by email service)
|
||||||
|
this.eventEmitter.emit('user.registered', {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
firstName: dto.firstName,
|
||||||
|
lastName: dto.lastName,
|
||||||
|
},
|
||||||
|
...tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LOGIN
|
||||||
|
// ==========================================
|
||||||
|
async login(dto: LoginDto, userAgent?: string, ipAddress?: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { email: dto.email.toLowerCase() },
|
||||||
|
include: {
|
||||||
|
userProfile: true,
|
||||||
|
agentProfile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'This account uses social login. Please sign in with Google or Facebook.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status !== UserStatus.ACTIVE) {
|
||||||
|
throw new UnauthorizedException('Account is not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isPasswordValid = await argon2.verify(user.password, dto.password);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
await this.createSession(
|
||||||
|
user.id,
|
||||||
|
tokens.accessToken,
|
||||||
|
tokens.refreshToken,
|
||||||
|
userAgent,
|
||||||
|
ipAddress,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get profile based on role
|
||||||
|
const profile =
|
||||||
|
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
firstName: profile?.firstName,
|
||||||
|
lastName: profile?.lastName,
|
||||||
|
avatar: profile?.avatar,
|
||||||
|
},
|
||||||
|
...tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// SOCIAL AUTH
|
||||||
|
// ==========================================
|
||||||
|
async socialAuth(dto: SocialAuthDto) {
|
||||||
|
const providerIdField =
|
||||||
|
dto.provider === 'google' ? 'googleId' : 'facebookId';
|
||||||
|
|
||||||
|
// Check if user exists with this provider ID
|
||||||
|
let user = await this.prisma.user.findFirst({
|
||||||
|
where: { [providerIdField]: dto.providerId },
|
||||||
|
include: {
|
||||||
|
userProfile: true,
|
||||||
|
agentProfile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Check if user exists with this email
|
||||||
|
user = await this.prisma.user.findUnique({
|
||||||
|
where: { email: dto.email.toLowerCase() },
|
||||||
|
include: {
|
||||||
|
userProfile: true,
|
||||||
|
agentProfile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Link provider to existing account
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { [providerIdField]: dto.providerId },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
const role = dto.role === 'AGENT' ? UserRole.AGENT : UserRole.USER;
|
||||||
|
const authProvider =
|
||||||
|
dto.provider === 'google'
|
||||||
|
? AuthProvider.GOOGLE
|
||||||
|
: AuthProvider.FACEBOOK;
|
||||||
|
|
||||||
|
// Parse name
|
||||||
|
const nameParts = dto.name?.split(' ') || [];
|
||||||
|
const firstName = nameParts[0] || '';
|
||||||
|
const lastName = nameParts.slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: dto.email.toLowerCase(),
|
||||||
|
role,
|
||||||
|
status: UserStatus.ACTIVE,
|
||||||
|
emailVerified: true,
|
||||||
|
emailVerifiedAt: new Date(),
|
||||||
|
authProvider,
|
||||||
|
[providerIdField]: dto.providerId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
userProfile: true,
|
||||||
|
agentProfile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create profile based on role
|
||||||
|
if (role === UserRole.USER) {
|
||||||
|
await this.prisma.userProfile.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
avatar: dto.avatar,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (role === UserRole.AGENT) {
|
||||||
|
const slug = await this.generateUniqueSlug(firstName, lastName);
|
||||||
|
await this.prisma.agentProfile.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
slug,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
avatar: dto.avatar,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refetch user with profile
|
||||||
|
user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
include: {
|
||||||
|
userProfile: true,
|
||||||
|
agentProfile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException('Failed to create or find user');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status !== UserStatus.ACTIVE) {
|
||||||
|
throw new UnauthorizedException('Account is not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
await this.createSession(user.id, tokens.accessToken, tokens.refreshToken);
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile =
|
||||||
|
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
firstName: profile?.firstName,
|
||||||
|
lastName: profile?.lastName,
|
||||||
|
avatar: profile?.avatar,
|
||||||
|
},
|
||||||
|
...tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// FORGOT PASSWORD
|
||||||
|
// ==========================================
|
||||||
|
async forgotPassword(dto: ForgotPasswordDto) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { email: dto.email.toLowerCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always return success to prevent email enumeration
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
message: 'If the email exists, a password reset link will be sent',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
return {
|
||||||
|
message: 'If the email exists, a password reset link will be sent',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate reset token (short-lived JWT)
|
||||||
|
const resetToken = this.jwtService.sign(
|
||||||
|
{ sub: user.id, email: user.email, type: 'password-reset' },
|
||||||
|
{
|
||||||
|
secret: this.configService.get<string>('jwt.secret'),
|
||||||
|
expiresIn: '1h',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit event for email service
|
||||||
|
this.eventEmitter.emit('password.reset-requested', {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
resetToken,
|
||||||
|
resetUrl: `${this.configService.get<string>('app.frontendUrl')}/reset-password?token=${resetToken}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'If the email exists, a password reset link will be sent',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// RESET PASSWORD
|
||||||
|
// ==========================================
|
||||||
|
async resetPassword(dto: ResetPasswordDto) {
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.verify(dto.token, {
|
||||||
|
secret: this.configService.get<string>('jwt.secret'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload.type !== 'password-reset') {
|
||||||
|
throw new BadRequestException('Invalid reset token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const hashedPassword = await argon2.hash(dto.password);
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { password: hashedPassword },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate all existing sessions
|
||||||
|
await this.prisma.session.deleteMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
this.eventEmitter.emit('password.reset-completed', {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Password reset successful. Please login with your new password.',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
throw new BadRequestException('Reset token has expired');
|
||||||
|
}
|
||||||
|
if (error.name === 'JsonWebTokenError') {
|
||||||
|
throw new BadRequestException('Invalid reset token');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REFRESH TOKEN
|
||||||
|
// ==========================================
|
||||||
|
async refreshToken(dto: RefreshTokenDto) {
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.verify(dto.refreshToken, {
|
||||||
|
secret: this.configService.get<string>('jwt.secret'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if session exists
|
||||||
|
const session = await this.prisma.session.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: payload.sub,
|
||||||
|
refreshToken: dto.refreshToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || user.status !== UserStatus.ACTIVE) {
|
||||||
|
throw new UnauthorizedException('User not found or inactive');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
const tokens = await this.generateTokens(user.id, user.email, user.role);
|
||||||
|
|
||||||
|
// Update session
|
||||||
|
await this.prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: {
|
||||||
|
token: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
expiresAt: new Date(
|
||||||
|
Date.now() +
|
||||||
|
this.parseExpiration(
|
||||||
|
this.configService.get<string>('jwt.refreshExpiration') || '7d',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error.name === 'TokenExpiredError' ||
|
||||||
|
error.name === 'JsonWebTokenError'
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// LOGOUT
|
||||||
|
// ==========================================
|
||||||
|
async logout(userId: string, accessToken?: string) {
|
||||||
|
if (accessToken) {
|
||||||
|
// Delete specific session
|
||||||
|
await this.prisma.session.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
token: accessToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Delete all sessions for user
|
||||||
|
await this.prisma.session.deleteMany({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Logged out successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// GET CURRENT USER
|
||||||
|
// ==========================================
|
||||||
|
async getCurrentUser(userId: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
userProfile: true,
|
||||||
|
agentProfile: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile =
|
||||||
|
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
authProvider: user.authProvider,
|
||||||
|
firstName: profile?.firstName,
|
||||||
|
lastName: profile?.lastName,
|
||||||
|
avatar: profile?.avatar,
|
||||||
|
phone: profile?.phone,
|
||||||
|
city: profile?.city,
|
||||||
|
state: profile?.state,
|
||||||
|
country: profile?.country,
|
||||||
|
...(user.role === UserRole.AGENT && user.agentProfile
|
||||||
|
? {
|
||||||
|
slug: user.agentProfile.slug,
|
||||||
|
bio: user.agentProfile.bio,
|
||||||
|
headline: user.agentProfile.headline,
|
||||||
|
yearsOfExperience: user.agentProfile.yearsOfExperience,
|
||||||
|
licenseNumber: user.agentProfile.licenseNumber,
|
||||||
|
companyName: user.agentProfile.companyName,
|
||||||
|
isProfileComplete: user.agentProfile.isProfileComplete,
|
||||||
|
profileCompleteness: user.agentProfile.profileCompleteness,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// HELPER METHODS
|
||||||
|
// ==========================================
|
||||||
|
private async generateTokens(userId: string, email: string, role: UserRole) {
|
||||||
|
const payload = { sub: userId, email, role };
|
||||||
|
const secret = this.configService.get<string>('jwt.secret');
|
||||||
|
const accessExpiresIn = this.parseExpirationToSeconds(
|
||||||
|
this.configService.get<string>('jwt.accessExpiration') || '15m',
|
||||||
|
);
|
||||||
|
const refreshExpiresIn = this.parseExpirationToSeconds(
|
||||||
|
this.configService.get<string>('jwt.refreshExpiration') || '7d',
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessToken = this.jwtService.sign(payload, {
|
||||||
|
secret,
|
||||||
|
expiresIn: accessExpiresIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshToken = this.jwtService.sign(payload, {
|
||||||
|
secret,
|
||||||
|
expiresIn: refreshExpiresIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseExpirationToSeconds(expiration: string): number {
|
||||||
|
const match = expiration.match(/^(\d+)([smhd])$/);
|
||||||
|
if (!match) return 900; // Default 15 minutes
|
||||||
|
|
||||||
|
const value = parseInt(match[1], 10);
|
||||||
|
const unit = match[2];
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 's':
|
||||||
|
return value;
|
||||||
|
case 'm':
|
||||||
|
return value * 60;
|
||||||
|
case 'h':
|
||||||
|
return value * 60 * 60;
|
||||||
|
case 'd':
|
||||||
|
return value * 24 * 60 * 60;
|
||||||
|
default:
|
||||||
|
return 900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createSession(
|
||||||
|
userId: string,
|
||||||
|
accessToken: string,
|
||||||
|
refreshToken: string,
|
||||||
|
userAgent?: string,
|
||||||
|
ipAddress?: string,
|
||||||
|
) {
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() +
|
||||||
|
this.parseExpiration(
|
||||||
|
this.configService.get<string>('jwt.refreshExpiration') || '7d',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.session.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
token: accessToken,
|
||||||
|
refreshToken,
|
||||||
|
userAgent,
|
||||||
|
ipAddress,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseExpiration(expiration: string): number {
|
||||||
|
const match = expiration.match(/^(\d+)([smhd])$/);
|
||||||
|
if (!match) return 7 * 24 * 60 * 60 * 1000; // Default 7 days
|
||||||
|
|
||||||
|
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 7 * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateUniqueSlug(
|
||||||
|
firstName?: string,
|
||||||
|
lastName?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const baseName = `${firstName || ''} ${lastName || ''}`.trim() || 'agent';
|
||||||
|
let slug = slugify(baseName, { lower: true, strict: true });
|
||||||
|
|
||||||
|
// Check if slug exists
|
||||||
|
let counter = 0;
|
||||||
|
let uniqueSlug = slug;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const existing = await this.prisma.agentProfile.findUnique({
|
||||||
|
where: { slug: uniqueSlug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) break;
|
||||||
|
|
||||||
|
counter++;
|
||||||
|
uniqueSlug = `${slug}-${counter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueSlug;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/auth/decorators/current-user.decorator.ts
Normal file
14
src/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: string | undefined, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data ? user[data] : user;
|
||||||
|
},
|
||||||
|
);
|
||||||
3
src/auth/decorators/index.ts
Normal file
3
src/auth/decorators/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './public.decorator';
|
||||||
|
export * from './roles.decorator';
|
||||||
|
export * from './current-user.decorator';
|
||||||
4
src/auth/decorators/public.decorator.ts
Normal file
4
src/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
5
src/auth/decorators/roles.decorator.ts
Normal file
5
src/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
11
src/auth/dto/forgot-password.dto.ts
Normal file
11
src/auth/dto/forgot-password.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsEmail } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ForgotPasswordDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'john@example.com',
|
||||||
|
description: 'Email address to send password reset link',
|
||||||
|
})
|
||||||
|
@IsEmail({}, { message: 'Please provide a valid email address' })
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
6
src/auth/dto/index.ts
Normal file
6
src/auth/dto/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './register.dto';
|
||||||
|
export * from './login.dto';
|
||||||
|
export * from './social-auth.dto';
|
||||||
|
export * from './forgot-password.dto';
|
||||||
|
export * from './reset-password.dto';
|
||||||
|
export * from './refresh-token.dto';
|
||||||
19
src/auth/dto/login.dto.ts
Normal file
19
src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'john@example.com',
|
||||||
|
description: 'User email address',
|
||||||
|
})
|
||||||
|
@IsEmail({}, { message: 'Please provide a valid email address' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Password@123',
|
||||||
|
description: 'User password',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1, { message: 'Password is required' })
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
11
src/auth/dto/refresh-token.dto.ts
Normal file
11
src/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
description: 'Refresh token',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
65
src/auth/dto/register.dto.ts
Normal file
65
src/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Matches,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'john@example.com',
|
||||||
|
description: 'User email address',
|
||||||
|
})
|
||||||
|
@IsEmail({}, { message: 'Please provide a valid email address' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'Password@123',
|
||||||
|
description: 'Password (min 8 chars, 1 uppercase, 1 lowercase, 1 number, 1 special char)',
|
||||||
|
minLength: 8,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8, { message: 'Password must be at least 8 characters long' })
|
||||||
|
@MaxLength(50, { message: 'Password must not exceed 50 characters' })
|
||||||
|
@Matches(
|
||||||
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'USER',
|
||||||
|
description: 'User role (USER or AGENT)',
|
||||||
|
enum: ['USER', 'AGENT'],
|
||||||
|
default: 'USER',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['USER', 'AGENT'], { message: 'Role must be either USER or AGENT' })
|
||||||
|
role?: 'USER' | 'AGENT';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'John',
|
||||||
|
description: 'First name',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'Doe',
|
||||||
|
description: 'Last name',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
lastName?: string;
|
||||||
|
}
|
||||||
28
src/auth/dto/reset-password.dto.ts
Normal file
28
src/auth/dto/reset-password.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { IsString, MinLength, MaxLength, Matches } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'abc123token',
|
||||||
|
description: 'Password reset token from email',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'NewPassword@123',
|
||||||
|
description: 'New password (min 8 chars, 1 uppercase, 1 lowercase, 1 number, 1 special char)',
|
||||||
|
minLength: 8,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8, { message: 'Password must be at least 8 characters long' })
|
||||||
|
@MaxLength(50, { message: 'Password must not exceed 50 characters' })
|
||||||
|
@Matches(
|
||||||
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
60
src/auth/dto/social-auth.dto.ts
Normal file
60
src/auth/dto/social-auth.dto.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsString,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsUrl,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class SocialAuthDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'google',
|
||||||
|
description: 'OAuth provider',
|
||||||
|
enum: ['google', 'facebook'],
|
||||||
|
})
|
||||||
|
@IsEnum(['google', 'facebook'], {
|
||||||
|
message: 'Provider must be either google or facebook',
|
||||||
|
})
|
||||||
|
provider: 'google' | 'facebook';
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: '123456789',
|
||||||
|
description: 'Provider user ID',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
providerId: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'john@example.com',
|
||||||
|
description: 'User email from OAuth provider',
|
||||||
|
})
|
||||||
|
@IsEmail({}, { message: 'Please provide a valid email address' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'John Doe',
|
||||||
|
description: 'User name from OAuth provider',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'https://example.com/avatar.jpg',
|
||||||
|
description: 'User avatar URL from OAuth provider',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({}, { message: 'Avatar must be a valid URL' })
|
||||||
|
avatar?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'USER',
|
||||||
|
description: 'User role (USER or AGENT)',
|
||||||
|
enum: ['USER', 'AGENT'],
|
||||||
|
default: 'USER',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['USER', 'AGENT'], { message: 'Role must be either USER or AGENT' })
|
||||||
|
role?: 'USER' | 'AGENT';
|
||||||
|
}
|
||||||
2
src/auth/guards/index.ts
Normal file
2
src/auth/guards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './jwt-auth.guard';
|
||||||
|
export * from './roles.guard';
|
||||||
36
src/auth/guards/jwt-auth.guard.ts
Normal file
36
src/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
ExecutionContext,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
|
constructor(private reflector: Reflector) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext) {
|
||||||
|
// Check if route is marked as public
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRequest(err: any, user: any, info: any) {
|
||||||
|
if (err || !user) {
|
||||||
|
throw err || new UnauthorizedException('Invalid or expired token');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/auth/guards/roles.guard.ts
Normal file
41
src/auth/guards/roles.guard.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
||||||
|
ROLES_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!requiredRoles || requiredRoles.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRole = requiredRoles.some((role) => user.role === role);
|
||||||
|
|
||||||
|
if (!hasRole) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Access denied. Required roles: ${requiredRoles.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/auth/index.ts
Normal file
7
src/auth/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './auth.module';
|
||||||
|
export * from './auth.service';
|
||||||
|
export * from './auth.controller';
|
||||||
|
export * from './dto';
|
||||||
|
export * from './guards';
|
||||||
|
export * from './strategies';
|
||||||
|
export * from './decorators';
|
||||||
1
src/auth/strategies/index.ts
Normal file
1
src/auth/strategies/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './jwt.strategy';
|
||||||
60
src/auth/strategies/jwt.strategy.ts
Normal file
60
src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
iat?: number;
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
|
const secret = configService.get<string>('jwt.secret');
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('JWT_SECRET is not configured');
|
||||||
|
}
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: secret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
status: true,
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status !== 'ACTIVE') {
|
||||||
|
throw new UnauthorizedException('Account is not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/users/index.ts
Normal file
3
src/users/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './users.module';
|
||||||
|
export * from './users.service';
|
||||||
|
export * from './users.controller';
|
||||||
117
src/users/users.controller.ts
Normal file
117
src/users/users.controller.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
|
||||||
|
import { Roles } from '../auth/decorators';
|
||||||
|
import { UserRole, UserStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
@ApiTags('Users')
|
||||||
|
@Controller('users')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Get all users (Admin only)' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'List of users',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
email: 'user@example.com',
|
||||||
|
role: 'USER',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
emailVerified: true,
|
||||||
|
authProvider: 'LOCAL',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
lastLoginAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
profile: {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
avatar: null,
|
||||||
|
phone: null,
|
||||||
|
city: null,
|
||||||
|
state: null,
|
||||||
|
country: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 100,
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
totalPages: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Forbidden - Admin only' })
|
||||||
|
async findAll(
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
const pageNum = page ? parseInt(page, 10) : 1;
|
||||||
|
const limitNum = limit ? parseInt(limit, 10) : 10;
|
||||||
|
|
||||||
|
return this.usersService.findAll(pageNum, limitNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Get user by ID (Admin only)' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'User details',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: 'User not found' })
|
||||||
|
async findOne(@Param('id') id: string) {
|
||||||
|
const user = await this.usersService.findOne(id);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
@ApiOperation({ summary: 'Update user status (Admin only)' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'User status updated',
|
||||||
|
})
|
||||||
|
async updateStatus(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body('status') status: UserStatus,
|
||||||
|
) {
|
||||||
|
const user = await this.usersService.updateStatus(id, status);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
status: user.status,
|
||||||
|
message: 'User status updated successfully',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/users/users.module.ts
Normal file
12
src/users/users.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
88
src/users/users.service.ts
Normal file
88
src/users/users.service.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { UserStatus, Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
type UserWithProfile = Prisma.UserGetPayload<{
|
||||||
|
include: { userProfile: true };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(page = 1, limit = 10) {
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
this.prisma.user.findMany({
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
userProfile: true,
|
||||||
|
},
|
||||||
|
}) as Promise<UserWithProfile[]>,
|
||||||
|
this.prisma.user.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: users.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
authProvider: user.authProvider,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
profile: user.userProfile
|
||||||
|
? {
|
||||||
|
firstName: user.userProfile.firstName,
|
||||||
|
lastName: user.userProfile.lastName,
|
||||||
|
avatar: user.userProfile.avatar,
|
||||||
|
phone: user.userProfile.phone,
|
||||||
|
city: user.userProfile.city,
|
||||||
|
state: user.userProfile.state,
|
||||||
|
country: user.userProfile.country,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const user = (await this.prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
userProfile: true,
|
||||||
|
},
|
||||||
|
})) as UserWithProfile | null;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
authProvider: user.authProvider,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
lastLoginAt: user.lastLoginAt,
|
||||||
|
profile: user.userProfile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, status: UserStatus) {
|
||||||
|
return this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user