add : address,favorite

This commit is contained in:
2026-03-30 09:50:40 +03:00
parent b146cfb595
commit 648e5c213f
11 changed files with 621 additions and 4 deletions

View File

@@ -18,8 +18,10 @@ This first phase covers:
- wallet summary
- wallet transaction list
- active session list and revoke
- address management
- favorite products
Orders, addresses, favorites, and support tickets are not implemented yet in this phase.
Support tickets and orders are not implemented yet in this phase.
## Endpoints
@@ -66,6 +68,7 @@ Response includes:
- `stats.walletBalance`
- `stats.walletTransactionsCount`
- `stats.activeSessionsCount`
- `stats.addressesCount`
- `stats.ordersCount`
- `stats.favoritesCount`
- `stats.ticketsCount`
@@ -74,7 +77,6 @@ Response includes:
Current placeholder values:
- `ordersCount = 0`
- `favoritesCount = 0`
- `ticketsCount = 0`
These fields are placeholders until those modules are implemented.
@@ -113,3 +115,75 @@ 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.
## Address APIs
These endpoints support both user-panel address management and checkout address selection.
### `GET /api/users/me/addresses`
Returns all saved addresses for the current user.
The default address is returned first.
### `POST /api/users/me/addresses`
Creates a new address.
Request body:
```json
{
"title": "خانه",
"recipientName": "علی رضایی",
"phone": "+989121234567",
"province": "فارس",
"city": "شیراز",
"postalCode": "7183914511",
"addressLine": "بلوار معالی آباد، کوچه 12، ساختمان پارس",
"plaque": "24",
"unit": "3",
"isDefault": true
}
```
Notes:
- each user can have multiple addresses
- if this is the first address, it automatically becomes default
- if `isDefault=true`, previous default address is cleared
### `PATCH /api/users/me/addresses/:addressId`
Updates one saved address.
Notes:
- `isDefault=true` makes this address the default
- removing the only default address state is blocked
### `PATCH /api/users/me/addresses/:addressId/default`
Sets one address as the default address.
### `DELETE /api/users/me/addresses/:addressId`
Deletes one address.
If the deleted address was default, the newest remaining address becomes default automatically.
## Favorites APIs
### `GET /api/users/me/favorites`
Returns favorite products for the current user.
### `POST /api/users/me/favorites/:productId`
Adds one product to favorites.
If the product already exists in favorites, the API returns the existing favorite entry.
### `DELETE /api/users/me/favorites/:productId`
Removes one product from favorites.

View File

@@ -26,6 +26,8 @@ import { SettingsModule } from './modules/settings/settings.module';
import { StorageModule } from './modules/storage/storage.module';
import { LoyaltyProfile } from './modules/users/entities/loyalty-profile.entity';
import { User } from './modules/users/entities/user.entity';
import { FavoriteProduct } from './modules/users/entities/favorite-product.entity';
import { UserAddress } from './modules/users/entities/user-address.entity';
import { UserLevelHistory } from './modules/users/entities/user-level-history.entity';
import { WalletTransaction } from './modules/users/entities/wallet-transaction.entity';
import { Wallet } from './modules/users/entities/wallet.entity';
@@ -46,6 +48,8 @@ import { UsersModule } from './modules/users/users.module';
WalletTransaction,
LoyaltyProfile,
UserLevelHistory,
UserAddress,
FavoriteProduct,
AuthOtp,
UserSession,
Product,

View File

@@ -13,6 +13,8 @@ import { Settings } from '../modules/settings/entities/settings.entity';
import { MediaAsset } from '../modules/media/entities/media-asset.entity';
import { LoyaltyProfile } from '../modules/users/entities/loyalty-profile.entity';
import { User } from '../modules/users/entities/user.entity';
import { FavoriteProduct } from '../modules/users/entities/favorite-product.entity';
import { UserAddress } from '../modules/users/entities/user-address.entity';
import { UserLevelHistory } from '../modules/users/entities/user-level-history.entity';
import { WalletTransaction } from '../modules/users/entities/wallet-transaction.entity';
import { Wallet } from '../modules/users/entities/wallet.entity';
@@ -33,6 +35,8 @@ export const buildTypeOrmOptions = (
WalletTransaction,
LoyaltyProfile,
UserLevelHistory,
UserAddress,
FavoriteProduct,
AuthOtp,
UserSession,
Product,

View File

@@ -0,0 +1,63 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsOptional,
IsString,
Length,
Matches,
} from 'class-validator';
export class CreateUserAddressDto {
@ApiProperty({ example: 'خانه' })
@IsString()
@Length(2, 100)
title: string;
@ApiProperty({ example: 'علی رضایی' })
@IsString()
@Length(2, 150)
recipientName: string;
@ApiProperty({ example: '+989121234567' })
@IsString()
@Length(8, 20)
phone: string;
@ApiProperty({ example: 'فارس' })
@IsString()
@Length(2, 100)
province: string;
@ApiProperty({ example: 'شیراز' })
@IsString()
@Length(2, 100)
city: string;
@ApiPropertyOptional({ example: '7183914511' })
@IsOptional()
@IsString()
@Matches(/^[0-9]{5,20}$/)
postalCode?: string;
@ApiProperty({ example: 'بلوار معالی آباد، کوچه 12، ساختمان پارس' })
@IsString()
@Length(5, 500)
addressLine: string;
@ApiPropertyOptional({ example: '24' })
@IsOptional()
@IsString()
@Length(1, 50)
plaque?: string;
@ApiPropertyOptional({ example: '3' })
@IsOptional()
@IsString()
@Length(1, 50)
unit?: string;
@ApiPropertyOptional({ example: false })
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateUserAddressDto } from './create-user-address.dto';
export class UpdateUserAddressDto extends PartialType(CreateUserAddressDto) {}

View File

@@ -0,0 +1,31 @@
import {
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { Product } from '../../catalog/entities/product.entity';
import { User } from './user.entity';
@Entity({ name: 'favorite_products' })
@Unique('UQ_favorite_products_user_product', ['user', 'product'])
export class FavoriteProduct {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@ManyToOne(() => User, (user) => user.favoriteProducts, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Index()
@ManyToOne(() => Product, { onDelete: 'CASCADE', eager: true })
@JoinColumn({ name: 'product_id' })
product: Product;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@@ -0,0 +1,58 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity({ name: 'user_addresses' })
export class UserAddress {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@ManyToOne(() => User, (user) => user.addresses, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ length: 100 })
title: string;
@Column({ name: 'recipient_name', length: 150 })
recipientName: string;
@Column({ length: 20 })
phone: string;
@Column({ length: 100 })
province: string;
@Column({ length: 100 })
city: string;
@Column({ name: 'postal_code', length: 20, nullable: true })
postalCode?: string | null;
@Column({ name: 'address_line', type: 'varchar', length: 500 })
addressLine: string;
@Column({ length: 50, nullable: true })
plaque?: string | null;
@Column({ length: 50, nullable: true })
unit?: string | null;
@Column({ type: 'boolean', name: 'is_default', default: false })
isDefault: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -9,7 +9,9 @@ import {
} from 'typeorm';
import { UserSession } from '../../auth/entities/user-session.entity';
import { UserRole } from '../enums/user-role.enum';
import { FavoriteProduct } from './favorite-product.entity';
import { LoyaltyProfile } from './loyalty-profile.entity';
import { UserAddress } from './user-address.entity';
import { Wallet } from './wallet.entity';
@Entity({ name: 'users' })
@@ -50,6 +52,12 @@ export class User {
@OneToMany(() => UserSession, (session) => session.user)
sessions: UserSession[];
@OneToMany(() => UserAddress, (address) => address.user)
addresses: UserAddress[];
@OneToMany(() => FavoriteProduct, (favorite) => favorite.user)
favoriteProducts: FavoriteProduct[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -5,6 +5,7 @@ import {
Get,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
@@ -17,7 +18,9 @@ import {
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { JwtPayload } from '../auth/interfaces/jwt-payload.interface';
import { CreateUserAddressDto } from './dto/create-user-address.dto';
import { ListWalletTransactionsDto } from './dto/list-wallet-transactions.dto';
import { UpdateUserAddressDto } from './dto/update-user-address.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { UsersService } from './users.service';
@@ -78,4 +81,71 @@ export class UsersController {
) {
return this.usersService.revokeSession(request.user.sub, sessionId);
}
@Get('me/addresses')
@ApiOperation({ summary: 'List saved addresses for current user' })
getAddresses(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getAddresses(request.user.sub);
}
@Post('me/addresses')
@ApiOperation({ summary: 'Create a new address for current user' })
createAddress(
@Req() request: Request & { user: JwtPayload },
@Body() dto: CreateUserAddressDto,
) {
return this.usersService.createAddress(request.user.sub, dto);
}
@Patch('me/addresses/:addressId')
@ApiOperation({ summary: 'Update one address for current user' })
updateAddress(
@Req() request: Request & { user: JwtPayload },
@Param('addressId') addressId: string,
@Body() dto: UpdateUserAddressDto,
) {
return this.usersService.updateAddress(request.user.sub, addressId, dto);
}
@Patch('me/addresses/:addressId/default')
@ApiOperation({ summary: 'Set one address as default for current user' })
setDefaultAddress(
@Req() request: Request & { user: JwtPayload },
@Param('addressId') addressId: string,
) {
return this.usersService.setDefaultAddress(request.user.sub, addressId);
}
@Delete('me/addresses/:addressId')
@ApiOperation({ summary: 'Delete one address for current user' })
deleteAddress(
@Req() request: Request & { user: JwtPayload },
@Param('addressId') addressId: string,
) {
return this.usersService.deleteAddress(request.user.sub, addressId);
}
@Get('me/favorites')
@ApiOperation({ summary: 'List favorite products for current user' })
getFavorites(@Req() request: Request & { user: JwtPayload }) {
return this.usersService.getFavorites(request.user.sub);
}
@Post('me/favorites/:productId')
@ApiOperation({ summary: 'Add one product to current user favorites' })
addFavorite(
@Req() request: Request & { user: JwtPayload },
@Param('productId') productId: string,
) {
return this.usersService.addFavorite(request.user.sub, productId);
}
@Delete('me/favorites/:productId')
@ApiOperation({ summary: 'Remove one product from current user favorites' })
removeFavorite(
@Req() request: Request & { user: JwtPayload },
@Param('productId') productId: string,
) {
return this.usersService.removeFavorite(request.user.sub, productId);
}
}

View File

@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserSession } from '../auth/entities/user-session.entity';
import { Product } from '../catalog/entities/product.entity';
import { FavoriteProduct } from './entities/favorite-product.entity';
import { LoyaltyProfile } from './entities/loyalty-profile.entity';
import { User } from './entities/user.entity';
import { UserAddress } from './entities/user-address.entity';
import { UserLevelHistory } from './entities/user-level-history.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { Wallet } from './entities/wallet.entity';
@@ -18,6 +21,9 @@ import { UsersService } from './users.service';
LoyaltyProfile,
UserLevelHistory,
UserSession,
UserAddress,
FavoriteProduct,
Product,
]),
],
controllers: [UsersController],

View File

@@ -6,10 +6,15 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { UserSession } from '../auth/entities/user-session.entity';
import { Product } from '../catalog/entities/product.entity';
import { CreateUserAddressDto } from './dto/create-user-address.dto';
import { ListWalletTransactionsDto } from './dto/list-wallet-transactions.dto';
import { UpdateUserAddressDto } from './dto/update-user-address.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { FavoriteProduct } from './entities/favorite-product.entity';
import { LoyaltyProfile } from './entities/loyalty-profile.entity';
import { User } from './entities/user.entity';
import { UserAddress } from './entities/user-address.entity';
import { UserLevelHistory } from './entities/user-level-history.entity';
import { WalletTransaction } from './entities/wallet-transaction.entity';
import { Wallet } from './entities/wallet.entity';
@@ -31,6 +36,12 @@ export class UsersService {
private readonly walletTransactionsRepository: Repository<WalletTransaction>,
@InjectRepository(UserSession)
private readonly userSessionsRepository: Repository<UserSession>,
@InjectRepository(UserAddress)
private readonly userAddressesRepository: Repository<UserAddress>,
@InjectRepository(FavoriteProduct)
private readonly favoriteProductsRepository: Repository<FavoriteProduct>,
@InjectRepository(Product)
private readonly productsRepository: Repository<Product>,
) {}
findByPhone(phone: string) {
@@ -132,7 +143,13 @@ export class UsersService {
async getDashboard(userId: string) {
const user = await this.requireUser(userId);
const [activeSessionsCount, walletTransactionsCount, loyaltyHistory] =
const [
activeSessionsCount,
walletTransactionsCount,
loyaltyHistory,
favoritesCount,
addressesCount,
] =
await Promise.all([
this.userSessionsRepository.count({
where: {
@@ -150,6 +167,16 @@ export class UsersService {
order: { createdAt: 'DESC' },
take: 5,
}),
this.favoriteProductsRepository.count({
where: {
user: { id: user.id },
},
}),
this.userAddressesRepository.count({
where: {
user: { id: user.id },
},
}),
]);
return {
@@ -158,8 +185,9 @@ export class UsersService {
walletBalance: user.wallet.balance,
walletTransactionsCount,
activeSessionsCount,
addressesCount,
ordersCount: 0,
favoritesCount: 0,
favoritesCount,
ticketsCount: 0,
},
loyalty: {
@@ -276,6 +304,215 @@ export class UsersService {
};
}
async getAddresses(userId: string) {
const addresses = await this.userAddressesRepository.find({
where: { user: { id: userId } },
order: { isDefault: 'DESC', createdAt: 'DESC' },
});
return {
items: addresses.map((address) => this.mapAddress(address)),
};
}
async createAddress(userId: string, dto: CreateUserAddressDto) {
const user = await this.requireUser(userId);
const currentAddressesCount = await this.userAddressesRepository.count({
where: { user: { id: user.id } },
});
const shouldSetDefault = dto.isDefault === true || currentAddressesCount === 0;
if (shouldSetDefault) {
await this.clearDefaultAddresses(user.id);
}
const address = this.userAddressesRepository.create({
user,
title: dto.title,
recipientName: dto.recipientName,
phone: dto.phone,
province: dto.province,
city: dto.city,
postalCode: dto.postalCode,
addressLine: dto.addressLine,
plaque: dto.plaque,
unit: dto.unit,
isDefault: shouldSetDefault,
});
const savedAddress = await this.userAddressesRepository.save(address);
return this.mapAddress(savedAddress);
}
async updateAddress(userId: string, addressId: string, dto: UpdateUserAddressDto) {
const address = await this.requireAddress(userId, addressId);
if (dto.isDefault === true) {
await this.clearDefaultAddresses(userId);
address.isDefault = true;
}
if (dto.title !== undefined) {
address.title = dto.title;
}
if (dto.recipientName !== undefined) {
address.recipientName = dto.recipientName;
}
if (dto.phone !== undefined) {
address.phone = dto.phone;
}
if (dto.province !== undefined) {
address.province = dto.province;
}
if (dto.city !== undefined) {
address.city = dto.city;
}
if (dto.postalCode !== undefined) {
address.postalCode = dto.postalCode;
}
if (dto.addressLine !== undefined) {
address.addressLine = dto.addressLine;
}
if (dto.plaque !== undefined) {
address.plaque = dto.plaque;
}
if (dto.unit !== undefined) {
address.unit = dto.unit;
}
if (dto.isDefault === false) {
const otherDefault = await this.userAddressesRepository.findOne({
where: {
user: { id: userId },
isDefault: true,
},
});
if (!otherDefault || otherDefault.id === address.id) {
throw new BadRequestException('User must have at least one default address');
}
address.isDefault = false;
}
const savedAddress = await this.userAddressesRepository.save(address);
return this.mapAddress(savedAddress);
}
async setDefaultAddress(userId: string, addressId: string) {
const address = await this.requireAddress(userId, addressId);
await this.clearDefaultAddresses(userId);
address.isDefault = true;
const savedAddress = await this.userAddressesRepository.save(address);
return this.mapAddress(savedAddress);
}
async deleteAddress(userId: string, addressId: string) {
const address = await this.requireAddress(userId, addressId);
const wasDefault = address.isDefault;
await this.userAddressesRepository.remove(address);
if (wasDefault) {
const nextAddress = await this.userAddressesRepository.findOne({
where: { user: { id: userId } },
order: { createdAt: 'DESC' },
});
if (nextAddress) {
nextAddress.isDefault = true;
await this.userAddressesRepository.save(nextAddress);
}
}
return {
message: 'Address deleted successfully',
addressId,
};
}
async getFavorites(userId: string) {
const favorites = await this.favoriteProductsRepository.find({
where: { user: { id: userId } },
order: { createdAt: 'DESC' },
});
return {
items: favorites.map((favorite) => ({
id: favorite.id,
createdAt: favorite.createdAt,
product: this.mapFavoriteProduct(favorite.product),
})),
};
}
async addFavorite(userId: string, productId: string) {
const [user, product, existingFavorite] = await Promise.all([
this.requireUser(userId),
this.productsRepository.findOne({
where: { id: productId },
}),
this.favoriteProductsRepository.findOne({
where: {
user: { id: userId },
product: { id: productId },
},
}),
]);
if (!product) {
throw new NotFoundException('Product not found');
}
if (existingFavorite) {
return {
message: 'Product already exists in favorites',
favorite: {
id: existingFavorite.id,
createdAt: existingFavorite.createdAt,
product: this.mapFavoriteProduct(existingFavorite.product),
},
};
}
const favorite = this.favoriteProductsRepository.create({
user,
product,
});
const savedFavorite = await this.favoriteProductsRepository.save(favorite);
return {
message: 'Product added to favorites successfully',
favorite: {
id: savedFavorite.id,
createdAt: savedFavorite.createdAt,
product: this.mapFavoriteProduct(savedFavorite.product),
},
};
}
async removeFavorite(userId: string, productId: string) {
const favorite = await this.favoriteProductsRepository.findOne({
where: {
user: { id: userId },
product: { id: productId },
},
});
if (!favorite) {
throw new NotFoundException('Favorite product not found');
}
await this.favoriteProductsRepository.remove(favorite);
return {
message: 'Product removed from favorites successfully',
productId,
};
}
private async requireUser(userId: string) {
const user = await this.findById(userId);
@@ -286,6 +523,31 @@ export class UsersService {
return user;
}
private async requireAddress(userId: string, addressId: string) {
const address = await this.userAddressesRepository.findOne({
where: {
id: addressId,
user: { id: userId },
},
relations: { user: true },
});
if (!address) {
throw new NotFoundException('Address not found');
}
return address;
}
private async clearDefaultAddresses(userId: string) {
await this.userAddressesRepository
.createQueryBuilder()
.update(UserAddress)
.set({ isDefault: false })
.where('user_id = :userId', { userId })
.execute();
}
private mapProfile(user: User) {
return {
id: user.id,
@@ -321,4 +583,37 @@ export class UsersService {
createdAt: transaction.createdAt,
};
}
private mapAddress(address: UserAddress) {
return {
id: address.id,
title: address.title,
recipientName: address.recipientName,
phone: address.phone,
province: address.province,
city: address.city,
postalCode: address.postalCode,
addressLine: address.addressLine,
plaque: address.plaque,
unit: address.unit,
isDefault: address.isDefault,
createdAt: address.createdAt,
updatedAt: address.updatedAt,
};
}
private mapFavoriteProduct(product: Product) {
return {
id: product.id,
title: product.title,
slug: product.slug,
brand: product.brand,
mainImageUrl: product.mainImageUrl,
stock: product.stock,
status: product.status,
type: product.type,
averageRating: product.averageRating,
reviewsCount: product.reviewsCount,
};
}
}