add : UserAPI

This commit is contained in:
2026-03-30 08:49:21 +03:00
parent 1661f210b0
commit b146cfb595
8 changed files with 520 additions and 19 deletions

115
docs/users-api.md Normal file
View File

@@ -0,0 +1,115 @@
# Users API
Base URL: `/api`
All responses follow the standard response wrapper.
These endpoints require:
- Bearer token
- Authenticated user
## Current Scope
This first phase covers:
- current user profile
- dashboard summary
- wallet summary
- wallet transaction list
- active session list and revoke
Orders, addresses, favorites, and support tickets are not implemented yet in this phase.
## Endpoints
### `GET /api/users/me`
Returns current user profile:
- `id`
- `phone`
- `username`
- `fullName`
- `isVerified`
- `role`
- `wallet`
- `loyaltyProfile`
- `createdAt`
- `updatedAt`
### `PATCH /api/users/me`
Updates current user profile.
Request body:
```json
{
"fullName": "Ali Rezaei",
"username": "alirezaei"
}
```
Rules:
- `username` must be unique
- `username` allows letters, numbers, underscore, dot, and hyphen
### `GET /api/users/me/dashboard`
Returns dashboard summary for the authenticated user.
Response includes:
- `profile`
- `stats.walletBalance`
- `stats.walletTransactionsCount`
- `stats.activeSessionsCount`
- `stats.ordersCount`
- `stats.favoritesCount`
- `stats.ticketsCount`
- `loyalty`
Current placeholder values:
- `ordersCount = 0`
- `favoritesCount = 0`
- `ticketsCount = 0`
These fields are placeholders until those modules are implemented.
### `GET /api/users/me/wallet`
Returns:
- wallet balance
- transaction count
- latest 10 wallet transactions
### `GET /api/users/me/wallet/transactions`
Query params:
- `page`
- `limit`
- `type`: `deposit | withdraw`
### `GET /api/users/me/sessions`
Returns active sessions for the current user.
Each item includes:
- `id`
- `deviceInfo`
- `ipAddress`
- `expiresAt`
- `createdAt`
- `updatedAt`
Session metadata is captured during register, login, OTP verify, and refresh.
### `DELETE /api/users/me/sessions/:sessionId`
Revokes one active session for the current user.

View File

@@ -37,29 +37,32 @@ export class AuthController {
@Post('register/password')
@ApiOperation({ summary: 'Register with mobile, username, and password' })
@ApiBody({ type: RegisterPasswordDto })
registerWithPassword(@Body() dto: RegisterPasswordDto) {
return this.authService.registerWithPassword(dto);
registerWithPassword(
@Body() dto: RegisterPasswordDto,
@Req() request: Request,
) {
return this.authService.registerWithPassword(dto, request);
}
@Post('login/password')
@ApiOperation({ summary: 'Login with username and password' })
@ApiBody({ type: LoginPasswordDto })
loginWithPassword(@Body() dto: LoginPasswordDto) {
return this.authService.loginWithPassword(dto);
loginWithPassword(@Body() dto: LoginPasswordDto, @Req() request: Request) {
return this.authService.loginWithPassword(dto, request);
}
@Post('otp/verify')
@ApiOperation({ summary: 'Verify OTP code for mobile auth flow' })
@ApiBody({ type: VerifyOtpDto })
verifyOtp(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtp(dto.phone, dto.otp);
verifyOtp(@Body() dto: VerifyOtpDto, @Req() request: Request) {
return this.authService.verifyOtp(dto.phone, dto.otp, request);
}
@Post('refresh')
@ApiOperation({ summary: 'Refresh JWT access token' })
@ApiBody({ type: RefreshTokenDto })
refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refreshToken(dto.refreshToken);
refresh(@Body() dto: RefreshTokenDto, @Req() request: Request) {
return this.authService.refreshToken(dto.refreshToken, request);
}
@UseGuards(JwtAuthGuard)

View File

@@ -7,6 +7,7 @@ import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { Request } from 'express';
import { StringValue } from 'ms';
import { IsNull, Repository } from 'typeorm';
import { AuthOtp } from './entities/auth-otp.entity';
@@ -59,7 +60,7 @@ export class AuthService {
};
}
async registerWithPassword(dto: RegisterPasswordDto) {
async registerWithPassword(dto: RegisterPasswordDto, request?: Request) {
const existingPhone = await this.usersService.findByPhone(dto.phone);
if (existingPhone) {
throw new BadRequestException('Phone already exists');
@@ -79,12 +80,12 @@ export class AuthService {
role: UserRole.USER,
});
const tokens = await this.issueTokens(savedUser);
await this.storeRefreshToken(savedUser, tokens.refreshToken);
await this.storeRefreshToken(savedUser, tokens.refreshToken, request);
return tokens;
}
async loginWithPassword(dto: LoginPasswordDto) {
async loginWithPassword(dto: LoginPasswordDto, request?: Request) {
const user = await this.usersService.findByUsername(dto.username);
if (!user?.passwordHash) {
throw new UnauthorizedException('Invalid username or password');
@@ -96,12 +97,12 @@ export class AuthService {
}
const tokens = await this.issueTokens(user);
await this.storeRefreshToken(user, tokens.refreshToken);
await this.storeRefreshToken(user, tokens.refreshToken, request);
return tokens;
}
async verifyOtp(phone: string, otp: string) {
async verifyOtp(phone: string, otp: string, request?: Request) {
const user = await this.usersService.findByPhone(phone);
const otpRecord = await this.authOtpsRepository.findOne({
where: { phone, purpose: 'login', usedAt: IsNull() },
@@ -131,12 +132,12 @@ export class AuthService {
]);
const tokens = await this.issueTokens(user);
await this.storeRefreshToken(user, tokens.refreshToken);
await this.storeRefreshToken(user, tokens.refreshToken, request);
return tokens;
}
async refreshToken(refreshToken: string) {
async refreshToken(refreshToken: string, request?: Request) {
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
secret: this.configService.getOrThrow<string>('jwt.secret'),
});
@@ -169,7 +170,7 @@ export class AuthService {
await this.userSessionsRepository.save(validSession);
const tokens = await this.issueTokens(user);
await this.storeRefreshToken(user, tokens.refreshToken);
await this.storeRefreshToken(user, tokens.refreshToken, request);
return tokens;
}
@@ -227,12 +228,18 @@ export class AuthService {
};
}
private async storeRefreshToken(user: User, refreshToken: string) {
private async storeRefreshToken(
user: User,
refreshToken: string,
request?: Request,
) {
const refreshTtl = this.configService.getOrThrow<StringValue>('jwt.refreshTtl');
const session = this.userSessionsRepository.create({
user,
refreshTokenHash: await bcrypt.hash(refreshToken, 10),
expiresAt: new Date(Date.now() + this.parseDurationToMs(refreshTtl)),
deviceInfo: this.resolveDeviceInfo(request),
ipAddress: this.resolveIpAddress(request),
});
await this.userSessionsRepository.save(session);
}
@@ -301,4 +308,20 @@ export class AuthService {
return amount * unitMap[unit];
}
private resolveDeviceInfo(request?: Request) {
const userAgent = request?.get('user-agent')?.trim();
return userAgent ? userAgent.slice(0, 255) : null;
}
private resolveIpAddress(request?: Request) {
const forwardedFor = request?.headers['x-forwarded-for'];
const forwardedIp = Array.isArray(forwardedFor)
? forwardedFor[0]
: forwardedFor?.split(',')[0];
const ip = forwardedIp?.trim() || request?.ip || null;
return ip ? ip.slice(0, 64) : null;
}
}

View File

@@ -0,0 +1,25 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator';
export class ListWalletTransactionsDto {
@ApiPropertyOptional({ example: 1, default: 1 })
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ example: 20, default: 20 })
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
limit?: number = 20;
@ApiPropertyOptional({ example: 'deposit' })
@IsOptional()
@IsString()
@IsIn(['deposit', 'withdraw'])
type?: string;
}

View File

@@ -0,0 +1,20 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, Length, Matches } from 'class-validator';
export class UpdateProfileDto {
@ApiPropertyOptional({ example: 'Ali Rezaei' })
@IsOptional()
@IsString()
@Length(2, 150)
fullName?: string;
@ApiPropertyOptional({ example: 'alirezaei' })
@IsOptional()
@IsString()
@Length(3, 50)
@Matches(/^[a-zA-Z0-9_.-]+$/, {
message:
'username can only contain letters, numbers, underscore, dot, and hyphen',
})
username?: string;
}

View File

@@ -0,0 +1,81 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { JwtPayload } from '../auth/interfaces/jwt-payload.interface';
import { ListWalletTransactionsDto } from './dto/list-wallet-transactions.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { UsersService } from './users.service';
@ApiTags('Users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('me')
@ApiOperation({ summary: 'Get current authenticated user profile' })
getMe(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getProfile(request.user.sub);
}
@Patch('me')
@ApiOperation({ summary: 'Update current authenticated user profile' })
updateMe(
@Req() request: Request & { user: JwtPayload },
@Body() dto: UpdateProfileDto,
) {
return this.usersService.updateProfile(request.user.sub, dto);
}
@Get('me/dashboard')
@ApiOperation({ summary: 'Get dashboard summary for current user' })
getDashboard(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getDashboard(request.user.sub);
}
@Get('me/wallet')
@ApiOperation({ summary: 'Get wallet summary for current user' })
getWallet(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getWalletSummary(request.user.sub);
}
@Get('me/wallet/transactions')
@ApiOperation({ summary: 'List wallet transactions for current user' })
getWalletTransactions(
@Req() request: Request & { user: JwtPayload },
@Query() query: ListWalletTransactionsDto,
) {
return this.usersService.getWalletTransactions(request.user.sub, query);
}
@Get('me/sessions')
@ApiOperation({ summary: 'List active sessions for current user' })
getSessions(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getActiveSessions(request.user.sub);
}
@Delete('me/sessions/:sessionId')
@ApiOperation({ summary: 'Revoke one active session for current user' })
revokeSession(
@Req() request: Request & { user: JwtPayload },
@Param('sessionId') sessionId: string,
) {
return this.usersService.revokeSession(request.user.sub, sessionId);
}
}

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserSession } from '../auth/entities/user-session.entity';
import { LoyaltyProfile } from './entities/loyalty-profile.entity';
import { User } from './entities/user.entity';
import { UserLevelHistory } from './entities/user-level-history.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { Wallet } from './entities/wallet.entity';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
@@ -15,8 +17,10 @@ import { UsersService } from './users.service';
WalletTransaction,
LoyaltyProfile,
UserLevelHistory,
UserSession,
]),
],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})

View File

@@ -1,9 +1,17 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IsNull, Repository } from 'typeorm';
import { UserSession } from '../auth/entities/user-session.entity';
import { ListWalletTransactionsDto } from './dto/list-wallet-transactions.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { LoyaltyProfile } from './entities/loyalty-profile.entity';
import { User } from './entities/user.entity';
import { UserLevelHistory } from './entities/user-level-history.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { Wallet } from './entities/wallet.entity';
import { UserRole } from './enums/user-role.enum';
import { UserLevel } from './enums/user-level.enum';
@@ -19,6 +27,10 @@ export class UsersService {
private readonly loyaltyProfilesRepository: Repository<LoyaltyProfile>,
@InjectRepository(UserLevelHistory)
private readonly userLevelHistoriesRepository: Repository<UserLevelHistory>,
@InjectRepository(WalletTransaction)
private readonly walletTransactionsRepository: Repository<WalletTransaction>,
@InjectRepository(UserSession)
private readonly userSessionsRepository: Repository<UserSession>,
) {}
findByPhone(phone: string) {
@@ -91,4 +103,222 @@ export class UsersService {
async save(user: User) {
return this.usersRepository.save(user);
}
async getProfile(userId: string) {
const user = await this.requireUser(userId);
return this.mapProfile(user);
}
async updateProfile(userId: string, dto: UpdateProfileDto) {
const user = await this.requireUser(userId);
if (dto.username && dto.username !== user.username) {
const existingUser = await this.findByUsername(dto.username);
if (existingUser && existingUser.id !== user.id) {
throw new BadRequestException('Username already exists');
}
user.username = dto.username;
}
if (dto.fullName) {
user.fullName = dto.fullName;
}
const savedUser = await this.usersRepository.save(user);
return this.mapProfile(savedUser);
}
async getDashboard(userId: string) {
const user = await this.requireUser(userId);
const [activeSessionsCount, walletTransactionsCount, loyaltyHistory] =
await Promise.all([
this.userSessionsRepository.count({
where: {
user: { id: user.id },
revokedAt: IsNull(),
},
}),
this.walletTransactionsRepository.count({
where: {
wallet: { id: user.wallet.id },
},
}),
this.userLevelHistoriesRepository.find({
where: { loyaltyProfile: { id: user.loyaltyProfile.id } },
order: { createdAt: 'DESC' },
take: 5,
}),
]);
return {
profile: this.mapProfile(user),
stats: {
walletBalance: user.wallet.balance,
walletTransactionsCount,
activeSessionsCount,
ordersCount: 0,
favoritesCount: 0,
ticketsCount: 0,
},
loyalty: {
currentLevel: user.loyaltyProfile.currentLevel,
totalSpent: user.loyaltyProfile.totalSpent,
history: loyaltyHistory.map((item) => ({
id: item.id,
level: item.level,
reason: item.reason,
createdAt: item.createdAt,
})),
},
};
}
async getWalletSummary(userId: string) {
const user = await this.requireUser(userId);
const [recentTransactions, transactionsCount] = await Promise.all([
this.walletTransactionsRepository.find({
where: { wallet: { id: user.wallet.id } },
order: { createdAt: 'DESC' },
take: 10,
}),
this.walletTransactionsRepository.count({
where: { wallet: { id: user.wallet.id } },
}),
]);
return {
wallet: {
id: user.wallet.id,
balance: user.wallet.balance,
transactionsCount,
updatedAt: user.wallet.updatedAt,
},
recentTransactions: recentTransactions.map((item) =>
this.mapWalletTransaction(item),
),
};
}
async getWalletTransactions(userId: string, query: ListWalletTransactionsDto) {
const user = await this.requireUser(userId);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const qb = this.walletTransactionsRepository
.createQueryBuilder('transaction')
.innerJoin('transaction.wallet', 'wallet')
.where('wallet.id = :walletId', { walletId: user.wallet.id })
.orderBy('transaction.created_at', 'DESC')
.skip((page - 1) * limit)
.take(limit);
if (query.type) {
qb.andWhere('transaction.type = :type', { type: query.type });
}
const [items, total] = await qb.getManyAndCount();
return {
items: items.map((item) => this.mapWalletTransaction(item)),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
},
};
}
async getActiveSessions(userId: string) {
const user = await this.requireUser(userId);
const sessions = await this.userSessionsRepository.find({
where: {
user: { id: user.id },
revokedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
return {
items: sessions.map((session) => ({
id: session.id,
deviceInfo: session.deviceInfo,
ipAddress: session.ipAddress,
expiresAt: session.expiresAt,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
})),
};
}
async revokeSession(userId: string, sessionId: string) {
const session = await this.userSessionsRepository.findOne({
where: {
id: sessionId,
user: { id: userId },
revokedAt: IsNull(),
},
relations: { user: true },
});
if (!session) {
throw new NotFoundException('Session not found');
}
session.revokedAt = new Date();
await this.userSessionsRepository.save(session);
return {
message: 'Session revoked successfully',
sessionId: session.id,
};
}
private async requireUser(userId: string) {
const user = await this.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
private mapProfile(user: User) {
return {
id: user.id,
phone: user.phone,
username: user.username,
fullName: user.fullName,
isVerified: user.isVerified,
role: user.role,
wallet: user.wallet
? {
id: user.wallet.id,
balance: user.wallet.balance,
}
: null,
loyaltyProfile: user.loyaltyProfile
? {
id: user.loyaltyProfile.id,
currentLevel: user.loyaltyProfile.currentLevel,
totalSpent: user.loyaltyProfile.totalSpent,
}
: null,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
private mapWalletTransaction(transaction: WalletTransaction) {
return {
id: transaction.id,
type: transaction.type,
amount: transaction.amount,
description: transaction.description,
createdAt: transaction.createdAt,
};
}
}