This commit is contained in:
2026-03-09 11:38:43 +03:30
commit 5fda2d6d8c
339 changed files with 186841 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import { Module, forwardRef } from '@nestjs/common';
import { AdminService } from './providers/admin.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Admin } from './entities/admin.entity';
import { JwtModule } from '@nestjs/jwt';
import { AdminRepository } from './repositories/admin.repository';
import { Role } from '../roles/entities/role.entity';
import { Permission } from '../roles/entities/permission.entity';
import { RolePermission } from '../roles/entities/rolePermission.entity';
import { AdminController } from './controllers/admin.controller';
import { UtilsModule } from '../utils/utils.module';
import { RestaurantsModule } from '../restaurants/restaurants.module';
import { AdminRole } from './entities/adminRole.entity';
import { AdminRoleRepository } from './repositories/admin-role.repository';
@Module({
providers: [AdminService, AdminRepository, AdminRoleRepository],
controllers: [AdminController],
imports: [
MikroOrmModule.forFeature([Admin, Role, Permission, RolePermission, AdminRole]),
JwtModule,
UtilsModule,
forwardRef(() => RestaurantsModule),
],
exports: [AdminService, AdminRepository],
})
export class AdminModule { }
@@ -0,0 +1,104 @@
import { Controller, Post, Body, HttpCode, HttpStatus, Get, UseGuards, Patch, Param, Delete } from '@nestjs/common';
import { AdminService } from '../providers/admin.service';
import { CreateAdminDto } from '../dto/create-admin.dto';
import { UpdateAdminDto } from '../dto/update-admin.dto';
import { ApiTags, ApiOperation, ApiBody, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
import { RestId } from 'src/common/decorators';
import { AdminId } from 'src/common/decorators/admin-id.decorator';
import { Admin } from '../entities/admin.entity';
import { Permission } from 'src/common/enums/permission.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { CreateMyRestaurantAdminDto } from '../dto/create-my-restaurant-admin.dto';
@ApiBearerAuth()
@ApiTags('admin')
@Controller()
export class AdminController {
constructor(private readonly adminService: AdminService) { }
@Get('admin/admins')
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ADMINS)
getAll(@RestId() restId: string) {
return this.adminService.findAllByRestaurantId(restId);
}
@Get('admin/admins/profile')
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ADMINS)
getMe(@AdminId() adminId: string, @RestId() restId: string) {
return this.adminService.finAdminById(adminId, restId);
}
@Post('admin/admins')
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ADMINS)
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new admin' })
@ApiBody({ type: CreateAdminDto })
async create(@Body() dto: CreateAdminDto, @RestId() restId: string) {
const admin = await this.adminService.createAdminForMyRestaurant(restId, dto);
return admin;
}
@Patch('admin/admins/:adminId')
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ADMINS)
@ApiOperation({ summary: 'Update an admin' })
@ApiParam({ name: 'adminId', description: 'Admin ID' })
update(@Param('adminId') adminId: string, @Body() dto: UpdateAdminDto,
@RestId() restId: string) {
return this.adminService.update(adminId, restId, dto);
}
@Get('admin/admins/:adminId')
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ADMINS)
getById(@Param('adminId') adminId: string, @RestId() restId: string) {
return this.adminService.finAdminById(adminId, restId);
}
@Delete('admin/admins/:adminId')
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ADMINS)
@ApiOperation({ summary: 'Delete an admin by ID' })
@ApiParam({ name: 'id', description: 'Admin ID' })
deleteById(@Param('adminId') adminId: string, @RestId() restId: string) {
return this.adminService.remove(adminId, restId);
}
/** Super Admin Endpoints */
@UseGuards(SuperAdminAuthGuard)
@Get('super-admin/restaurants/:restaurantId/admins')
@ApiOperation({ summary: 'Get admins for a specific restaurant (Super Admin only)' })
@ApiParam({ name: 'restaurantId', description: 'Restaurant ID' })
getAdminForRestaurant(@Param('restaurantId') restaurantId: string) {
return this.adminService.getAdminsForRestaurantBySuperAdmin(restaurantId);
}
@UseGuards(SuperAdminAuthGuard)
@Post('super-admin/restaurants/:restaurantId/admins')
@ApiOperation({ summary: 'Create admin for a specific restaurant (Super Admin only)' })
@ApiParam({ name: 'restaurantId', description: 'Restaurant ID' })
@ApiBody({ type: CreateMyRestaurantAdminDto })
createAdminForRestaurant(
@Param('restaurantId') restaurantId: string,
@Body() dto: CreateMyRestaurantAdminDto,
): Promise<Admin> {
return this.adminService.createAdminForRestaurantBySuperAdmin(restaurantId, dto);
}
@UseGuards(SuperAdminAuthGuard)
@Delete('super-admin/restaurants/:restaurantId/admins/:adminId')
@ApiOperation({ summary: 'Revoke admin role from a restaurant (Super Admin only)' })
@ApiParam({ name: 'restaurantId', description: 'Restaurant ID' })
@ApiParam({ name: 'adminId', description: 'Admin ID' })
revokeAdminFromRestaurant(
@Param('restaurantId') restaurantId: string,
@Param('adminId') adminId: string,
) {
return this.adminService.remove(adminId, restaurantId);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateAdminDto {
@ApiProperty({ description: 'Mobile phone number' })
@IsNotEmpty()
@IsString()
phone!: string;
@ApiProperty({ description: 'First name', required: false })
@IsOptional()
@IsString()
firstName?: string;
@ApiProperty({ description: 'Last name', required: false })
@IsOptional()
@IsString()
lastName?: string;
@ApiProperty({ description: 'Role id for the admin' })
@IsNotEmpty()
@IsString()
roleId!: string;
}
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateMyRestaurantAdminDto {
@ApiProperty({ description: 'Mobile phone number' })
@IsNotEmpty()
@IsString()
phone!: string;
@ApiProperty({ description: 'First name', required: false })
@IsOptional()
@IsString()
firstName?: string;
@ApiProperty({ description: 'Last name', required: false })
@IsOptional()
@IsString()
lastName?: string;
@ApiProperty({ description: 'Role id for the admin' })
@IsNotEmpty()
@IsString()
roleId!: string;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateAdminDto } from './create-admin.dto';
export class UpdateAdminDto extends PartialType(CreateAdminDto) {}
@@ -0,0 +1,31 @@
import { Cascade, Collection, Entity, OneToMany, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { AdminRole } from './adminRole.entity';
import { normalizePhone } from '../../utils/phone.util';
@Entity({ tableName: 'admins' })
export class Admin extends BaseEntity {
@Property({ nullable: true })
firstName?: string;
@Property({ nullable: true })
lastName?: string;
private _phone!: string;
@Property({ unique: true })
get phone(): string {
return this._phone;
}
set phone(value: string) {
this._phone = normalizePhone(value);
}
@OneToMany(() => AdminRole, adminRole => adminRole.admin, {
cascade: [Cascade.ALL],
orphanRemoval: true,
})
roles = new Collection<AdminRole>(this);
}
@@ -0,0 +1,21 @@
import { Entity, Index, ManyToOne, Unique } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Role } from '../../roles/entities/role.entity';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
import { Admin } from './admin.entity';
@Entity({ tableName: 'admin_roles' })
@Unique({ properties: ['admin', 'restaurant'] })
@Index({ properties: ['admin', 'restaurant'] })
@Index({ properties: ['admin'] })
@Index({ properties: ['restaurant'] })
export class AdminRole extends BaseEntity {
@ManyToOne(() => Admin)
admin!: Admin;
@ManyToOne(() => Role)
role!: Role;
@ManyToOne(() => Restaurant, { nullable: true })
restaurant?: Restaurant;
}
@@ -0,0 +1,203 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Admin } from '../entities/admin.entity';
import { Role } from '../../roles/entities/role.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { wrap } from '@mikro-orm/core';
import { EntityManager } from '@mikro-orm/postgresql';
import { CreateMyRestaurantAdminDto } from '../dto/create-my-restaurant-admin.dto';
import { UpdateAdminDto } from '../dto/update-admin.dto';
import { AdminRole } from '../entities/adminRole.entity';
import { normalizePhone } from '../../utils/phone.util';
import { AdminRepository } from '../repositories/admin.repository';
import { AdminRoleRepository } from '../repositories/admin-role.repository';
@Injectable()
export class AdminService {
constructor(
private readonly adminRepository: AdminRepository,
private readonly adminRoleRepository: AdminRoleRepository,
private readonly em: EntityManager,
) { }
async findByPhone(phone: string) {
const normalizedPhone = normalizePhone(phone);
return this.adminRepository.findOne({ phone: normalizedPhone });
}
async finAdminById(
adminId: string,
restId: string,
) {
const admin = await this.adminRepository.findOne(
{ id: adminId, roles: { restaurant: { id: restId } } },
{ populate: ['roles'], exclude: ['roles'] },
);
if (!admin) {
throw new NotFoundException('Admin not found');
}
const adminPlain = wrap(admin).toObject();
const adminRole = await this.adminRoleRepository.findOne({ admin: admin, restaurant: { id: restId } });
return { ...adminPlain, role: adminRole?.role };
}
async findAllByRestaurantId(restId: string) {
return this.adminRepository.find({ roles: { restaurant: { id: restId } } }, { populate: ['roles', 'roles.role'] });
}
async createAdminForMyRestaurant(restId: string, dto: CreateMyRestaurantAdminDto) {
const { phone, firstName, lastName, roleId } = dto;
const normalizedPhone = normalizePhone(phone);
let admin: Admin | null = null;
admin = await this.adminRepository.findOne({
phone: normalizedPhone,
});
if (!admin) {
admin = this.adminRepository.create({
phone: normalizedPhone,
firstName,
lastName,
});
await this.em.persistAndFlush(admin);
}
const role = await this.em.findOne(Role, { id: roleId, isSystem: false });
if (!role) throw new NotFoundException('Role not found');
const restaurant = await this.em.findOne(Restaurant, { id: restId });
if (!restaurant) throw new NotFoundException('Restaurant not found');
let adminRole = await this.adminRoleRepository.findOne({
admin: admin,
restaurant: restaurant,
});
if (!adminRole) {
adminRole = this.adminRoleRepository.create({
admin: admin,
role,
restaurant,
});
} else {
adminRole.role = role;
}
await this.em.persistAndFlush(adminRole);
return admin;
}
async createAdminForRestaurantBySuperAdmin(restId: string, dto: CreateMyRestaurantAdminDto) {
const { phone, firstName, lastName, roleId } = dto;
const normalizedPhone = normalizePhone(phone);
let admin: Admin | null = null;
admin = await this.adminRepository.findOne({
phone: normalizedPhone,
});
if (!admin) {
admin = this.adminRepository.create({
phone: normalizedPhone,
firstName,
lastName,
});
await this.em.persistAndFlush(admin);
}
const role = await this.em.findOne(Role, { id: roleId });
if (!role) throw new NotFoundException('Role* not found' + roleId);
const restaurant = await this.em.findOne(Restaurant, { id: restId });
if (!restaurant) throw new NotFoundException('Restaurant not found');
let adminRole = await this.adminRoleRepository.findOne({
admin: admin,
restaurant: restaurant,
});
if (!adminRole) {
adminRole = this.adminRoleRepository.create({
admin: admin,
role,
restaurant,
});
} else {
adminRole.role = role;
}
await this.em.persistAndFlush(adminRole);
return admin;
}
async getAdminsForRestaurantBySuperAdmin(restId: string) {
const restaurant = await this.em.findOne(Restaurant, { id: restId });
if (!restaurant) throw new NotFoundException('Restaurant not found');
return this.adminRepository.find({ roles: { restaurant: restaurant } }, { populate: ['roles', 'roles.role'] });
}
async update(adminId: string, restId: string, dto: UpdateAdminDto) {
const admin = await this.adminRepository.findOne(
{ id: adminId, roles: { restaurant: { id: restId } } },
{ populate: ['roles', 'roles.role', 'roles.restaurant'] },
);
if (!admin) {
throw new NotFoundException('Admin not found');
}
const { roleId, ...rest } = dto;
// Update scalar fields (firstName, lastName, phone)
if (rest.firstName !== undefined) {
admin.firstName = rest.firstName;
}
if (rest.lastName !== undefined) {
admin.lastName = rest.lastName;
}
if (rest.phone !== undefined && rest.phone !== admin.phone) {
const normalizedPhone = normalizePhone(rest.phone);
const exists = await this.adminRepository.findOne({ phone: normalizedPhone });
if (exists) {
throw new ConflictException('This Phone Number is already taken');
}
admin.phone = normalizedPhone;
}
// Update role if roleId is provided
if (roleId) {
const role = await this.em.findOne(Role, { id: roleId, isSystem: false });
if (!role) {
throw new NotFoundException('Role not found');
}
// Find existing AdminRole for this admin and restaurant
const existingAdminRole = await this.em.findOne(AdminRole, {
admin: { id: adminId },
restaurant: { id: restId },
});
if (existingAdminRole) {
// Update existing role
existingAdminRole.role = role;
} else {
const restaurant = await this.em.findOne(Restaurant, { id: restId });
if (!restaurant) throw new NotFoundException('Restaurant not found');
// Create new AdminRole
const newAdminRole = this.em.create(AdminRole, {
admin,
role,
restaurant,
});
admin.roles.add(newAdminRole);
}
}
await this.em.persistAndFlush(admin);
return admin;
}
async remove(adminId: string, restId: string): Promise<void> {
const adminRole = await this.adminRoleRepository.findOne({ admin: { id: adminId }, restaurant: { id: restId } });
if (!adminRole) {
throw new NotFoundException('Admin role not found');
}
return this.em.removeAndFlush(adminRole);
}
}
@@ -0,0 +1,13 @@
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { AdminRole } from '../entities/adminRole.entity';
import { Injectable } from '@nestjs/common';
@Injectable()
export class AdminRoleRepository extends EntityRepository<AdminRole> {
constructor(
readonly em: EntityManager,
) {
super(em, AdminRole);
}
}
@@ -0,0 +1,58 @@
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { Admin } from '../entities/admin.entity';
import { AdminRole } from '../entities/adminRole.entity';
import { RestRepository } from '../../restaurants/repositories/rest.repository';
import { Injectable } from '@nestjs/common';
import { normalizePhone } from '../../utils/phone.util';
@Injectable()
export class AdminRepository extends EntityRepository<Admin> {
constructor(
readonly em: EntityManager,
private readonly restRepository: RestRepository,
) {
super(em, Admin);
}
async findByPhoneAndRestaurantSlug(phone: string, slug: string): Promise<Admin | null> {
console.log('phone', phone);
const normalizedPhone = normalizePhone(phone);
// First, find the restaurant by slug using the same repository as auth service
const restaurant = await this.restRepository.findOne({ slug });
if (!restaurant) {
return null;
}
// Find AdminRole that matches the admin phone and restaurant
const adminRole = await this.em.findOne(
AdminRole,
{
admin: { phone: normalizedPhone },
restaurant: { id: restaurant.id },
},
{ populate: ['admin', 'admin.roles', 'role', 'role.permissions', 'restaurant'] },
);
if (!adminRole || !adminRole.admin) {
return null;
}
// Ensure all roles are populated
await adminRole.admin.roles.loadItems();
for (const role of adminRole.admin.roles.getItems()) {
if (role.role && role.role.permissions && !role.role.permissions.isInitialized()) {
await role.role.permissions.loadItems();
}
}
return adminRole.admin;
}
async findAdminsWithPermission(restaurantId: string, permission: string): Promise<Admin[]> {
const admins = await this.em.find(
Admin,
{ roles: { restaurant: { id: restaurantId }, role: { permissions: { name: permission } } } },
{ populate: ['roles', 'roles.role', 'roles.role.permissions'] },
);
return admins;
}
}
@@ -0,0 +1,22 @@
import type { Admin } from '../entities/admin.entity';
export interface AdminDetailResponse {
id: string;
firstName?: string;
lastName?: string;
phone: string;
role: {
id: string;
name: string;
};
permissions: string[];
restaurant?: {
id: string;
name: string;
slug: string;
};
}
// Extract permissions - ensure collection is loaded if needed
+45
View File
@@ -0,0 +1,45 @@
import { Module, forwardRef } from '@nestjs/common';
import { AuthService } from './services/auth.service';
import { AuthController } from './controllers/auth.controller';
import { UtilsModule } from '../utils/utils.module';
import { UserModule } from '../users/user.module';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { AdminModule } from '../admin/admin.module';
import { TokensService } from './services/tokens.service';
import { RestaurantsModule } from '../restaurants/restaurants.module';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { User } from '../users/entities/user.entity';
import { AdminAuthGuard } from './guards/adminAuth.guard';
import { RefreshToken } from './entities/refresh-token.entity';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [
UtilsModule,
forwardRef(() => UserModule),
JwtModule.registerAsync({
useFactory: (configService: ConfigService) => {
const expiresIn = configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
return {
global: true,
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
// Use string format with time unit for explicit expiration (value should be in seconds)
expiresIn: `${expiresIn}s`,
},
};
},
inject: [ConfigService],
}),
forwardRef(() => AdminModule),
forwardRef(() => RestaurantsModule),
MikroOrmModule.forFeature([User, RefreshToken]),
forwardRef(() => NotificationsModule),
],
controllers: [AuthController],
providers: [AuthService, TokensService, AdminAuthGuard],
exports: [AdminAuthGuard],
})
export class AuthModule {}
@@ -0,0 +1,71 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { AuthService } from '../services/auth.service';
import { RequestOtpDto } from '../dto/request-otp.dto';
import { DirectLoginDto } from '../dto/direct-login.dto';
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
import { VerifyOtpDto } from '../dto/verify-otp.dto';
import { Throttle } from '@nestjs/throttler';
import { RefreshTokenRateLimit } from 'src/common/decorators/rate-limit.decorator';
import { RefreshTokenDto } from '../dto/refresh-token.dto';
import { SuperAdminAuthGuard } from '../guards/superAdminAuth.guard';
@ApiTags('auth')
@Controller()
export class AuthController {
constructor(private readonly authService: AuthService) { }
@Throttle({ default: { limit: 3, ttl: 180_000 } })
@Post('public/auth/otp/request')
@ApiOperation({ summary: 'Request OTP for login or signup' })
@ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' })
otpRequest(@Body() dto: RequestOtpDto) {
return this.authService.requestOtp(dto, false);
}
@Post('public/auth/otp/verify')
@ApiOperation({ summary: 'Verify OTP code' })
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
otpVerify(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtp(dto.phone, dto.slug, dto.otp); // assuming dto has `otp` property
}
@RefreshTokenRateLimit()
@ApiOperation({ summary: 'refresh the user access token / refresh token' })
@Post('public/auth/refresh')
refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshToken(refreshTokenDto.refreshToken);
}
@Throttle({ default: { limit: 3, ttl: 180_000 } })
@Post('admin/auth/otp/request')
@ApiOperation({ summary: 'Request OTP for login or signup' })
@ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' })
otpRequestAdmin(@Body() dto: RequestOtpDto) {
return this.authService.requestOtp(dto, true);
}
@Post('admin/auth/otp/verify')
@ApiOperation({ summary: 'Verify OTP code' })
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
otpVerifyAdmin(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtpAdmin(dto.phone, dto.slug, dto.otp);
}
@RefreshTokenRateLimit()
@ApiOperation({ summary: 'refresh the admin access token / refresh token' })
@Post('admin/auth/refresh')
refreshTokenAdmin(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshToken(refreshTokenDto.refreshToken);
}
//super admin routes
@UseGuards(SuperAdminAuthGuard)
@Post('super-admin/auth/direct-login')
@ApiOperation({ summary: 'Direct login for DSC - returns login credentials' })
@ApiBody({ type: DirectLoginDto, description: 'Phone number and restaurant slug for direct login' })
directloginForDsc(@Body() dto: DirectLoginDto) {
return this.authService.loginAdminForDsc(dto.phone, dto.slug);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { IsNotEmpty, IsString, IsMobilePhone } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class DirectLoginDto {
@IsNotEmpty()
@IsString()
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
@IsMobilePhone('fa-IR')
phone: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ example: 'zhivan', description: 'Restaurant slug' })
slug: string;
}
+9
View File
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class RefreshTokenDto {
@ApiProperty({ description: 'Refresh token' })
@IsString({ message: 'Refresh token must be a string' })
@IsNotEmpty({ message: 'Refresh token is required' })
refreshToken: string;
}
+15
View File
@@ -0,0 +1,15 @@
import { IsNotEmpty, IsString, IsMobilePhone } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RequestOtpDto {
@IsNotEmpty()
@IsString()
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
@IsMobilePhone('fa-IR')
phone: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ example: 'zhivan', description: 'restaurant slug' })
slug: string;
}
+21
View File
@@ -0,0 +1,21 @@
import { IsNotEmpty, IsString, Length, IsMobilePhone } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class VerifyOtpDto {
@IsNotEmpty()
@IsString()
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
@IsMobilePhone('fa-IR')
phone: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ example: '', description: 'Otp' })
@Length(5)
otp: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ example: 'zhivan', description: 'restaurant slug' })
slug: string;
}
@@ -0,0 +1,29 @@
import { Entity, Enum, Index, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
export enum RefreshTokenType {
USER = 'user',
ADMIN = 'admin',
}
@Entity({ tableName: 'refreshtokens' })
@Index({ properties: ['ownerId', 'restId', 'type'] })
@Index({ properties: ['hashedToken'] })
@Index({ properties: ['expiresAt'] })
export class RefreshToken extends BaseEntity {
@Property({ type: 'varchar', length: 255 })
hashedToken!: string;
@Property()
ownerId!: string;
@Property()
restId!: string;
@Enum(() => RefreshTokenType)
type!: RefreshTokenType;
@Property({ type: 'timestamptz' })
expiresAt!: Date;
}
+120
View File
@@ -0,0 +1,120 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
Inject,
Logger,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { IAdminTokenPayload } from '../interfaces/IToken-payload';
import { PERMISSIONS_KEY } from '../../../common/decorators/permissions.decorator';
import { PermissionsService } from 'src/modules/roles/providers/permissions.service';
export interface AdminAuthRequest extends Request {
adminId: string;
restId: string;
}
@Injectable()
export class AdminAuthGuard implements CanActivate {
private readonly logger = new Logger(AdminAuthGuard.name);
constructor(
@Inject(JwtService)
private readonly jwtService: JwtService,
@Inject(ConfigService)
private readonly configService: ConfigService,
private readonly permissionsService: PermissionsService,
private readonly reflector: Reflector,
) { }
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<AdminAuthRequest>();
const token = this.extractTokenFromHeader(request);
if (!token) {
this.logger.warn('No token provided', {
hasAuthHeader: !!request.headers.authorization,
authHeader: request.headers.authorization ? 'present' : 'missing',
headers: Object.keys(request.headers),
});
throw new UnauthorizedException('No token provided');
}
try {
// Get the JWT secret from config
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
// Verify token with the secret
const payload = await this.jwtService.verifyAsync<IAdminTokenPayload>(token, {
secret,
});
if (!payload.adminId || !payload.restId) {
this.logger.error('Invalid token payload structure', payload);
throw new UnauthorizedException('Invalid token payload');
}
request['adminId'] = payload.adminId;
request['restId'] = payload.restId;
// check if the user has the required permissions
const requiredPermissions =
this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [context.getHandler(), context.getClass()]) ?? [];
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const adminPermission = await this.permissionsService.getAdminPermissions(payload.adminId, payload.restId);
if (!adminPermission || !Array.isArray(adminPermission)) {
this.logger.error('No permissions found', { adminId: payload.adminId, restId: payload.restId });
throw new ForbiddenException('No permissions found');
}
const hasPermission = requiredPermissions.every(p => adminPermission.includes(p));
if (!hasPermission) {
this.logger.warn('Insufficient permissions', {
adminId: payload.adminId,
restId: payload.restId,
required: requiredPermissions,
has: adminPermission,
});
throw new ForbiddenException('You are not authorized to access this resource');
}
return true;
} catch (err) {
if (err instanceof ForbiddenException || err instanceof UnauthorizedException) {
throw err;
}
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
const errorStack = err instanceof Error ? err.stack : undefined;
this.logger.error('Token verification error in AdminAuthGuard', {
error: errorMessage,
stack: errorStack,
});
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractTokenFromHeader(request: Request): string | undefined {
const authHeader = request.headers.authorization || request.headers['authorization'];
if (!authHeader) {
return undefined;
}
const [type, token] = authHeader.split(' ');
if (type?.toLowerCase() !== 'bearer' || !token) {
return undefined;
}
return token;
}
}
+72
View File
@@ -0,0 +1,72 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Inject, Logger } from '@nestjs/common';
import { Request } from 'express';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { ITokenPayload } from '../interfaces/IToken-payload';
export interface UserAuthRequest extends Request {
userId: string;
restId: string;
}
@Injectable()
export class AuthGuard implements CanActivate {
private readonly logger = new Logger(AuthGuard.name);
constructor(
@Inject(JwtService)
private readonly jwtService: JwtService,
@Inject(ConfigService)
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<UserAuthRequest>();
try {
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
const token = this.extractTokenFromHeader(request);
const slug = this.extractSlugFromHeader(request);
if (!slug) {
throw new UnauthorizedException('Slug is required');
}
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
secret,
});
if (!payload.userId || !payload.restId) {
this.logger.error('Invalid token payload structure', payload);
throw new UnauthorizedException('Invalid token payload');
}
if (payload.slug !== slug) {
throw new UnauthorizedException('Invalid slug');
}
request['userId'] = payload.userId;
request['restId'] = payload.restId;
} catch {
throw new UnauthorizedException();
}
return true;
} catch (err) {
if (err instanceof UnauthorizedException) {
throw err;
}
this.logger.error('error in AuthGuard', err);
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
private extractSlugFromHeader(request: Request): string | undefined {
// Fastify normalizes headers to lowercase, so check both cases
const slug = (request.headers['x-slug'] || request.headers['X-Slug']) as string;
return slug;
}
}
@@ -0,0 +1,94 @@
import { CanActivate, ExecutionContext, Injectable, Inject, Logger } from '@nestjs/common';
import { Request } from 'express';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { ITokenPayload } from '../interfaces/IToken-payload';
export interface UserOptionalAuthRequest extends Request {
userId?: string;
restId?: string;
slug?: string;
}
@Injectable()
export class OptionalAuthGuard implements CanActivate {
private readonly logger = new Logger(OptionalAuthGuard.name);
constructor(
@Inject(JwtService)
private readonly jwtService: JwtService,
@Inject(ConfigService)
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<UserOptionalAuthRequest>();
const token = this.extractTokenFromHeader(request);
const slug = this.extractSlugFromHeader(request);
// If no token is provided, allow the request without authentication
if (!slug) {
console.log('No slug provided');
this.logger.warn('No slug provided');
return false;
}
if (!token) {
request['slug'] = slug;
return true;
}
// Try to verify the token if it exists
try {
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
secret,
});
// Validate payload structure
if (!payload.userId || !payload.restId) {
this.logger.warn('Invalid token payload structure', payload);
// Allow request but don't set user info
request['slug'] = slug;
return true;
}
// Validate slug if provided
if (slug && payload.slug !== slug) {
this.logger.warn('Token slug does not match header slug', {
tokenSlug: payload.slug,
headerSlug: slug,
});
// Allow request but don't set user info
request['slug'] = slug;
return true;
}
// Token is valid, set user info
request['userId'] = payload.userId;
request['restId'] = payload.restId;
request['slug'] = slug;
return true;
} catch (err) {
// Token is invalid or expired, but allow the request anyway
this.logger.debug('Token verification failed in OptionalAuthGuard', {
error: err instanceof Error ? err.message : 'Unknown error',
});
// Set restId from slug
request['slug'] = slug;
return true;
}
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
private extractSlugFromHeader(request: Request): string | undefined {
// Fastify normalizes headers to lowercase, so check both cases
const slug = (request.headers['x-slug'] || request.headers['X-Slug']) as string;
return slug;
}
}
@@ -0,0 +1,66 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Inject, Logger } from '@nestjs/common';
import { Request } from 'express';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SuperAdminAuthGuard implements CanActivate {
private readonly logger = new Logger(SuperAdminAuthGuard.name);
constructor(
@Inject(ConfigService)
private readonly configService: ConfigService,
) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
try {
const credentials = this.extractBasicAuthCredentials(request);
if (!credentials) {
throw new UnauthorizedException('Basic authentication required');
}
const { username, password } = credentials;
const expectedUsername = this.configService.get<string>('SUPER_ADMIN_USERNAME') ?? 'danak@dsc.com';
const expectedPassword = this.configService.get<string>('SUPER_ADMIN_PASSWORD') ?? 'DsCdAnAk?@ABC';
if (username !== expectedUsername || password !== expectedPassword) {
this.logger.warn(`Invalid super admin credentials attempt for username: ${username}`);
throw new UnauthorizedException('Invalid credentials');
}
return true;
} catch (err) {
if (err instanceof UnauthorizedException) {
throw err;
}
this.logger.error('error in SuperAdminAuthGuard', err);
throw new UnauthorizedException('Authentication failed');
}
}
private extractBasicAuthCredentials(request: Request): { username: string; password: string } | null {
const authHeader = request.headers.authorization;
if (!authHeader) {
return null;
}
const [type, encoded] = authHeader.split(' ');
if (type?.toLowerCase() !== 'basic' || !encoded) {
return null;
}
try {
const decoded = Buffer.from(encoded, 'base64').toString('utf-8');
const [username, password] = decoded.split(':');
if (!username || !password) {
return null;
}
return { username, password };
} catch (error) {
this.logger.error('Failed to decode Basic Auth credentials', error);
return null;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
export interface ITokenPayload {
userId: string;
restId: string;
slug: string;
}
export interface IAdminTokenPayload {
adminId: string;
restId: string;
}
+144
View File
@@ -0,0 +1,144 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { RequestOtpDto } from '../dto/request-otp.dto';
import { CacheService } from '../../utils/cache.service';
import { SmsService } from '../../notifications/services/sms.service';
import { UserService } from '../../users/providers/user.service';
import { randomInt } from 'crypto';
import { TokensService } from './tokens.service';
import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
import { AuthMessage, RestMessage } from 'src/common/enums/message.enum';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
import { AdminLoginTransformer } from '../transformers/admin-login.transformer';
import { UserLoginTransformer } from '../transformers/user-login.transformer';
import { ConfigService } from '@nestjs/config';
import { normalizePhone } from '../../utils/phone.util';
@Injectable()
export class AuthService {
readonly ADMIN_PERMISSIONS_KEY = 'admin-perms';
private OTP_EXPIRATION_TIME: number;
constructor(
private readonly cacheService: CacheService,
private readonly smsService: SmsService,
private readonly userService: UserService,
private readonly tokensService: TokensService,
private readonly restRepository: RestRepository,
private readonly adminRepository: AdminRepository,
private readonly configService: ConfigService,
) {
this.OTP_EXPIRATION_TIME = this.configService.get<number>('OTP_EXPIRATION_TIME') ?? 240;
}
private userOtpKey(restaurantSlug: string, phone: string) {
return `otp:${restaurantSlug}:${phone}`;
}
private adminOtpKey(restaurantSlug: string, phone: string) {
return `otp-admin:${restaurantSlug}:${phone}`;
}
async requestOtp(dto: RequestOtpDto, isAdmin: boolean) {
const { phone, slug } = dto;
const normalizedPhone = normalizePhone(phone);
if (isAdmin) {
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(normalizedPhone, slug);
if (!admin) {
throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND);
}
}
const code = this.generateOtpCode();
const key = isAdmin ? this.adminOtpKey(slug, normalizedPhone) : this.userOtpKey(slug, normalizedPhone);
await this.cacheService.set(key, code, this.OTP_EXPIRATION_TIME);
await this.smsService.sendotp(normalizedPhone, code);
return { code: null };
}
async verifyOtp(phone: string, slug: string, code: string) {
const normalizedPhone = normalizePhone(phone);
const key = this.userOtpKey(slug, normalizedPhone);
const cachedCode = await this.cacheService.get(key);
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
await this.cacheService.del(key);
const user = await this.userService.findOrCreateByPhone(normalizedPhone);
const rest = await this.restRepository.findOne({ slug });
if (!rest) {
throw new BadRequestException(RestMessage.NOT_FOUND);
}
const tokens = await this.tokensService.generateAccessAndRefreshToken(user.id, rest.id, false, slug);
const userResponse = UserLoginTransformer.transform(user, rest);
return { tokens, user: userResponse };
}
async verifyOtpAdmin(phone: string, slug: string, code: string) {
const normalizedPhone = normalizePhone(phone);
const key = this.adminOtpKey(slug, normalizedPhone);
const cachedCode = await this.cacheService.get(key);
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
await this.cacheService.del(key);
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(normalizedPhone, slug);
if (!admin) {
throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND);
}
const rest = await this.restRepository.findOne({ slug });
if (!rest) {
throw new BadRequestException(RestMessage.NOT_FOUND);
}
const tokens = await this.tokensService.generateAccessAndRefreshToken(admin.id, rest.id, true, slug);
const adminResponse = await AdminLoginTransformer.transform(admin, rest);
return { tokens, admin: adminResponse };
}
/**
*
to use for login directly from DSC
*/
async loginAdminForDsc(phone: string, slug: string) {
const normalizedPhone = normalizePhone(phone);
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(normalizedPhone, slug);
if (!admin) {
throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND);
}
const rest = await this.restRepository.findOne({ slug });
if (!rest) {
throw new BadRequestException(RestMessage.NOT_FOUND);
}
const tokens = await this.tokensService.generateAccessAndRefreshToken(admin.id, rest.id, true, slug);
const adminResponse = await AdminLoginTransformer.transform(admin, rest);
return { tokens, admin: adminResponse };
}
private generateOtpCode(): string {
const code = randomInt(10000, 100000);
return code.toString();
}
refreshToken(oldRefreshToken: string) {
return this.tokensService.refreshToken(oldRefreshToken);
}
}
+145
View File
@@ -0,0 +1,145 @@
import { createHash, randomBytes } from 'node:crypto';
import { EntityManager } from '@mikro-orm/postgresql';
import { LockMode } from '@mikro-orm/core';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import dayjs from 'dayjs';
import { AuthMessage } from '../../../common/enums/message.enum';
import { RefreshToken, RefreshTokenType } from '../entities/refresh-token.entity';
import { IAdminTokenPayload, ITokenPayload } from '../interfaces/IToken-payload';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
@Injectable()
export class TokensService {
private readonly logger = new Logger(TokensService.name);
constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
private readonly em: EntityManager,
) {}
async generateAccessAndRefreshToken(
ownerId: string,
restaurantId: string,
isAdmin: boolean,
slug: string,
em?: EntityManager,
) {
const refreshExpire = this.configService.getOrThrow<number>('REFRESH_TOKEN_EXPIRE');
const accessExpire = this.configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
const payload: ITokenPayload | IAdminTokenPayload = isAdmin
? { adminId: ownerId, restId: restaurantId }
: { userId: ownerId, restId: restaurantId, slug };
const accessToken = await this.generateAccessToken(payload, accessExpire);
const refreshToken = this.generateRefreshToken();
const type = isAdmin ? RefreshTokenType.ADMIN : RefreshTokenType.USER;
// Only pass em if it's a transaction manager (not this.em), otherwise pass undefined to trigger flush
await this.storeRefreshToken(ownerId, restaurantId, refreshToken, type, em);
return {
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'seconds').valueOf() },
refreshToken: { token: refreshToken, expire: dayjs().add(refreshExpire, 'day').valueOf() },
};
}
private generateAccessToken(payload: ITokenPayload | IAdminTokenPayload, expiresIn: number) {
// Ensure expiresIn is passed as a string with time unit for reliability
// JWT library accepts: number (seconds) or string with unit (e.g., "3600s", "1h")
// Using string format is more explicit and prevents unit confusion
return this.jwtService.signAsync(payload, { expiresIn: `${expiresIn}s` });
}
async storeRefreshToken(
ownerId: string,
restId: string,
refreshToken: string,
type: RefreshTokenType,
em?: EntityManager,
) {
const entityManager = em || this.em;
const refreshExpire = this.configService.getOrThrow<number>('REFRESH_TOKEN_EXPIRE');
const expiresAt = dayjs().add(refreshExpire, 'day').toDate();
const hashedToken = this.hashToken(refreshToken);
const token = entityManager.create(RefreshToken, {
hashedToken,
ownerId,
restId,
type,
expiresAt,
});
if (em) {
// Within transaction, just persist (flush will be called by transaction)
entityManager.persist(token);
} else {
// Outside transaction, persist and flush immediately
await entityManager.persistAndFlush(token);
}
}
async refreshToken(oldRefreshToken: string) {
const hashedToken = this.hashToken(oldRefreshToken);
// Use transaction to ensure atomicity and prevent race conditions
return this.em.transactional(async em => {
// Lock the token row to prevent concurrent refresh attempts
// Using pessimistic write lock (FOR UPDATE) to prevent race conditions
const token = await em.findOne(RefreshToken, { hashedToken }, { lockMode: LockMode.PESSIMISTIC_WRITE });
if (!token) {
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
}
// Check expiration
if (dayjs(token.expiresAt).isBefore(dayjs())) {
em.remove(token);
await em.flush();
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
}
// Store token data before removal
const ownerId = token.ownerId;
const restId = token.restId;
const isAdmin = token.type === RefreshTokenType.ADMIN;
// Verify restaurant still exists
const restaurant = await em.findOne(Restaurant, { id: restId });
if (!restaurant) {
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
}
try {
// Generate new tokens first (before removing old token)
// Pass the transaction EntityManager to ensure operations are within the transaction
const tokens = await this.generateAccessAndRefreshToken(ownerId, restaurant.id, isAdmin, restaurant.slug, em);
// Remove old token only after new token is created
em.remove(token);
// Persist changes atomically
await em.flush();
return tokens;
} catch (error) {
this.logger.error('Failed to generate new tokens after refresh', error);
// Transaction will rollback automatically, preserving the old token
throw new UnauthorizedException('Failed to refresh token');
}
});
}
private generateRefreshToken() {
return randomBytes(32).toString('hex');
}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
}
@@ -0,0 +1,93 @@
import type { Admin } from '../../admin/entities/admin.entity';
import type { Restaurant } from '../../restaurants/entities/restaurant.entity';
export interface AdminLoginResponse {
id: string;
firstName?: string;
lastName?: string;
phone: string;
role: string;
permissions: string[];
restaurant?: {
id: string;
name: string;
slug: string;
};
}
export class AdminLoginTransformer {
static async transform(admin: Admin, restaurant?: Restaurant): Promise<AdminLoginResponse> {
// Find the AdminRole that matches the restaurant (or get the first one)
let adminRole = admin.roles.getItems().find(r => (restaurant ? r.restaurant?.id === restaurant.id : true));
// If no match found, get the first one
if (!adminRole && admin.roles.getItems().length > 0) {
adminRole = admin.roles.getItems()[0];
}
if (!adminRole) {
return {
id: admin.id,
firstName: admin.firstName,
lastName: admin.lastName,
phone: admin.phone,
role: '',
permissions: [],
restaurant: restaurant
? {
id: restaurant.id,
name: restaurant.name,
slug: restaurant.slug,
}
: undefined,
};
}
// Get the role from AdminRole
const role = adminRole.role;
if (!role) {
return {
id: admin.id,
firstName: admin.firstName,
lastName: admin.lastName,
phone: admin.phone,
role: '',
permissions: [],
restaurant: restaurant
? {
id: restaurant.id,
name: restaurant.name,
slug: restaurant.slug,
}
: undefined,
};
}
// Extract permissions - ensure collection is loaded if needed
let permissions: string[] = [];
if (role.permissions) {
if (role.permissions.isInitialized()) {
permissions = role.permissions.getItems().map(p => p.name);
} else {
await role.permissions.loadItems();
permissions = role.permissions.getItems().map(p => p.name);
}
}
return {
id: admin.id,
firstName: admin.firstName,
lastName: admin.lastName,
phone: admin.phone,
role: role.name,
permissions,
restaurant: restaurant
? {
id: restaurant.id,
name: restaurant.name,
slug: restaurant.slug,
}
: undefined,
};
}
}
@@ -0,0 +1,33 @@
import type { User } from '../../users/entities/user.entity';
import type { Restaurant } from '../../restaurants/entities/restaurant.entity';
export interface UserLoginResponse {
id: string;
firstName: string;
lastName?: string;
phone: string;
isActive?: boolean;
restaurant: {
id: string;
name: string;
slug: string;
};
}
export class UserLoginTransformer {
static transform(user: User, restaurant: Restaurant): UserLoginResponse {
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
phone: user.phone,
isActive: user.isActive,
restaurant: {
id: restaurant.id,
name: restaurant.name,
slug: restaurant.slug,
},
};
}
}
+53
View File
@@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { CartService } from './providers/cart.service';
import { CartController } from './controllers/cart.controller';
import { Food } from '../foods/entities/food.entity';
import { Restaurant } from '../restaurants/entities/restaurant.entity';
import { PaymentMethod } from '../payments/entities/payment-method.entity';
import { UserAddress } from '../users/entities/user-address.entity';
import { User } from '../users/entities/user.entity';
import { Delivery } from '../delivery/entities/delivery.entity';
import { Order } from '../orders/entities/order.entity';
import { AuthModule } from '../auth/auth.module';
import { UserModule } from '../users/user.module';
import { JwtModule } from '@nestjs/jwt';
import { UtilsModule } from '../utils/utils.module';
import { CouponModule } from '../coupons/coupon.module';
import { CartRepository } from './repositories/cart.repository';
import { CartValidationService } from './providers/cart-validation.service';
import { CartCalculationService } from './providers/cart-calculation.service';
import { CartItemService } from './providers/cart-item.service';
import { PointTransaction } from '../users/entities/point-transaction.entity';
import { WalletTransaction } from '../users/entities/wallet-transaction.entity';
@Module({
imports: [
MikroOrmModule.forFeature([
Food,
Restaurant,
PaymentMethod,
UserAddress,
User,
PointTransaction,
WalletTransaction,
Delivery,
Order,
]),
AuthModule,
UserModule,
JwtModule,
UtilsModule,
CouponModule,
],
controllers: [CartController],
providers: [
CartService,
CartRepository,
CartValidationService,
CartCalculationService,
CartItemService,
],
exports: [CartService],
})
export class CartModule { }
@@ -0,0 +1,92 @@
import { Controller, Get, Post, Body, Patch, Delete, UseGuards, Param } from '@nestjs/common';
import { CartService } from '../providers/cart.service';
import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto';
import { ApplyCouponDto } from '../dto/apply-coupon.dto';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiParam, ApiHeader } from '@nestjs/swagger';
import { AuthGuard } from '../../auth/guards/auth.guard';
import { UserId } from 'src/common/decorators/user-id.decorator';
import { RestId } from 'src/common/decorators/rest-id.decorator';
import { SetAllCartParmsDto } from '../dto/set-all-cart-params.dto';
import { API_HEADER_SLUG } from 'src/common/constants/index';
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiTags('cart')
@Controller('public/cart')
export class CartController {
constructor(private readonly cartService: CartService) {}
@Get()
@ApiOperation({ summary: 'Get cart for current user and restaurant' })
@ApiHeader(API_HEADER_SLUG)
async findOne(@UserId() userId: string, @RestId() restaurantId: string) {
return this.cartService.getOrCreateCart(userId, restaurantId);
}
@Post('items/:foodId/increment')
@ApiOperation({ summary: 'Increment item quantity in cart by 1' })
@ApiHeader(API_HEADER_SLUG)
@ApiParam({ name: 'foodId', description: 'Food ID to increment in cart' })
async incrementItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) {
return this.cartService.incrementItem(userId, restaurantId, foodId, 1);
}
@Post('items/:foodId/decrement')
@ApiOperation({ summary: 'Decrement item quantity in cart by 1 (removes item if quantity reaches 0)' })
@ApiHeader(API_HEADER_SLUG)
@ApiParam({ name: 'foodId', description: 'Food ID to decrement in cart' })
async decrementItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) {
return this.cartService.decrementItem(userId, restaurantId, foodId);
}
@Post('items/bulk')
@ApiOperation({ summary: 'Bulk add items to cart (increments quantity if items exist)' })
@ApiHeader(API_HEADER_SLUG)
@ApiBody({ type: BulkAddItemsToCartDto })
bulkAddItems(
@UserId() userId: string,
@RestId() restaurantId: string,
@Body() bulkAddItemsDto: BulkAddItemsToCartDto,
) {
return this.cartService.bulkAddItems(userId, restaurantId, bulkAddItemsDto);
}
@Delete('items/:foodId')
@ApiOperation({ summary: 'Remove item from cart' })
@ApiHeader(API_HEADER_SLUG)
@ApiParam({ name: 'foodId', description: 'Food ID in the cart' })
async removeItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) {
return this.cartService.removeItem(userId, restaurantId, foodId);
}
@Delete()
@ApiOperation({ summary: 'Clear entire cart' })
@ApiHeader(API_HEADER_SLUG)
async clearCart(@UserId() userId: string, @RestId() restaurantId: string) {
return this.cartService.clearCart(userId, restaurantId);
}
@Post('apply-coupon')
@ApiOperation({ summary: 'Apply coupon to cart' })
@ApiHeader(API_HEADER_SLUG)
@ApiBody({ type: ApplyCouponDto })
async applyCoupon(@UserId() userId: string, @RestId() restaurantId: string, @Body() applyCouponDto: ApplyCouponDto) {
return this.cartService.applyCoupon(userId, restaurantId, applyCouponDto);
}
@Delete('coupon')
@ApiOperation({ summary: 'Remove coupon from cart' })
@ApiHeader(API_HEADER_SLUG)
async removeCoupon(@UserId() userId: string, @RestId() restaurantId: string) {
return this.cartService.removeCoupon(userId, restaurantId);
}
@Patch('all')
@ApiOperation({ summary: 'Set all cart params' })
@ApiHeader(API_HEADER_SLUG)
@ApiBody({ type: SetAllCartParmsDto })
setAllCartParams(@UserId() userId: string, @RestId() restaurantId: string, @Body() dto: SetAllCartParmsDto) {
return this.cartService.setAllCartParams(userId, restaurantId, dto);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class ApplyCouponDto {
@ApiProperty({ description: 'Coupon code', example: 'DISCOUNT10' })
@IsNotEmpty()
@IsString()
code!: string;
}
@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsInt, Min, IsString } from 'class-validator';
import { Type } from 'class-transformer';
class AddItemToCartDto {
@ApiProperty({ description: 'Quantity of the food item', example: 1, minimum: 1 })
@IsNotEmpty()
@Type(() => Number)
@IsInt()
@Min(1)
quantity!: number;
@ApiProperty({ description: 'Food ID' })
@IsNotEmpty()
@IsString()
foodId!: string;
}
export class BulkAddItemsToCartDto {
@ApiProperty({
description: 'Array of items to add to cart',
type: [AddItemToCartDto],
example: [
{ foodId: 'food-123', quantity: 2 },
{ foodId: 'food-456', quantity: 1 },
],
})
@IsNotEmpty()
@IsArray()
@ArrayMinSize(1, { message: 'At least one item is required' })
@ValidateNested({ each: true })
@Type(() => AddItemToCartDto)
items!: AddItemToCartDto[];
}
+29
View File
@@ -0,0 +1,29 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsArray, ValidateNested, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class CartItemDto {
@ApiProperty({ description: 'Food ID' })
@IsNotEmpty()
@IsString()
foodId!: string;
@ApiProperty({ description: 'Quantity of the food item', example: 2, minimum: 1 })
@IsNotEmpty()
@Type(() => Number)
@IsInt()
@Min(1)
quantity!: number;
}
export class CreateCartDto {
@ApiProperty({
description: 'List of cart items',
type: [CartItemDto],
})
@IsNotEmpty()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CartItemDto)
items!: CartItemDto[];
}
@@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
import { SetCarDeliveryDto } from './set-car-delivery.dto';
export class SetAllCartParmsDto {
@ApiProperty({
description: 'Cart description or notes',
required: false,
example: 'Please deliver to the back door',
})
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ description: 'Delivery method ID', example: '01ARZ3NDEKTSV4RRFFQ69G5FAV' })
@IsNotEmpty()
@IsString()
deliveryMethodId!: string;
@ApiProperty({ description: 'User address ID', example: '01ARZ3NDEKTSV4RRFFQ69G5FAV' })
@IsOptional()
@IsString()
addressId?: string;
@ApiProperty({ description: 'Payment method ID', example: '01ARZ3NDEKTSV4RRFFQ69G5FAV' })
@IsNotEmpty()
@IsString()
paymentMethodId!: string;
@ApiProperty({ description: 'Table number for dine-in orders', example: '5', required: false })
@IsOptional()
@IsString()
tableNumber?: string;
@ApiProperty()
@IsOptional()
@IsObject()
carAddress?: SetCarDeliveryDto;
}
@@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class SetCarDeliveryDto {
@ApiProperty({ description: 'Car model', example: 'Toyota Camry' })
@IsNotEmpty()
@IsString()
carModel!: string;
@ApiProperty({ description: 'Car color', example: 'White' })
@IsNotEmpty()
@IsString()
carColor!: string;
@ApiProperty({ description: 'License plate number', example: '12ABC345' })
@IsNotEmpty()
@IsString()
plateNumber!: string;
}
+9
View File
@@ -0,0 +1,9 @@
import { PartialType, ApiProperty } from '@nestjs/swagger';
import { CreateCartDto, CartItemDto } from './create-cart.dto';
export class UpdateCartItemDto extends PartialType(CartItemDto) {}
export class UpdateCartDto {
@ApiProperty({ description: 'List of cart items to update', type: [UpdateCartItemDto], required: false })
items?: UpdateCartItemDto[];
}
@@ -0,0 +1,38 @@
import type { OrderCouponDetail, OrderUserAddress, OrderCarAddress } from 'src/modules/orders/interface/order.interface';
export interface CartItem {
foodId: string;
foodTitle?: string;
quantity: number;
price: number;
discount: number;
totalPrice: number;
}
export interface Cart {
userId: string;
restaurantId: string;
restaurantName?: string;
items: CartItem[];
coupon?: OrderCouponDetail;
paymentMethodId?: string;
deliveryMethodId?: string;
description?: string;
tableNumber?: string;
deliveryFee: number;
subTotal: number;
tax: number;
couponDiscount: number;
itemsDiscount: number;
totalDiscount: number;
total: number;
carAddress?: OrderCarAddress | null;
userAddress?: OrderUserAddress | null;
totalItems: number;
createdAt: string;
updatedAt: string;
}
@@ -0,0 +1,244 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { Delivery } from '../../delivery/entities/delivery.entity';
import { DeliveryFeeTypeEnum } from '../../delivery/interface/delivery';
import { CouponType } from '../../coupons/interface/coupon';
import { Food } from '../../foods/entities/food.entity';
import { Cart } from '../interfaces/cart.interface';
import { CouponService } from '../../coupons/providers/coupon.service';
import { GeographicUtils } from '../utils/geographic.utils';
@Injectable()
export class CartCalculationService {
constructor(
private readonly em: EntityManager,
private readonly couponService: CouponService,
) {}
/**
* Calculate total price for a cart item
*/
calculateItemTotalPrice(unitPrice: number, unitDiscount: number, quantity: number): number {
const safeUnitPrice = Number(unitPrice) || 0;
const safeUnitDiscount = Math.min(Number(unitDiscount) || 0, safeUnitPrice);
const itemTotalPrice = safeUnitPrice * quantity;
const itemTotalDiscount = safeUnitDiscount * quantity;
return itemTotalPrice - itemTotalDiscount;
}
/**
* Calculate items totals (subtotal, items discount, total items)
*/
calculateItemsTotals(cart: Cart): { subTotal: number; itemsDiscount: number; totalItems: number } {
let subTotal = 0;
let itemsDiscount = 0;
let totalItems = 0;
for (const item of cart.items) {
const unitPrice = Number(item.price) || 0;
const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice);
const itemPrice = unitPrice * item.quantity;
const itemDiscount = unitDiscount * item.quantity;
subTotal += itemPrice;
itemsDiscount += itemDiscount;
totalItems += item.quantity;
item.totalPrice = itemPrice - itemDiscount;
}
return { subTotal, itemsDiscount, totalItems };
}
/**
* Calculate coupon discount
*/
async calculateCouponDiscount(cart: Cart, subTotal: number, itemsDiscount: number): Promise<number> {
if (!cart.coupon) return 0;
const coupon = await this.getCouponRestrictionsOrClear(cart);
if (!cart.coupon || !coupon) return 0;
const hasCategoryRestriction = (coupon.foodCategories?.length ?? 0) > 0;
const hasFoodRestriction = (coupon.foods?.length ?? 0) > 0;
let eligibleItemsTotal = subTotal;
let eligibleItemsDiscount = itemsDiscount;
if (hasCategoryRestriction || hasFoodRestriction) {
eligibleItemsTotal = 0;
eligibleItemsDiscount = 0;
const foodMap = await this.getFoodsInCartWithCategories(cart);
for (const item of cart.items) {
const food = foodMap.get(item.foodId);
if (!food) continue;
const matchesCategory =
hasCategoryRestriction && food.category && (coupon.foodCategories ?? []).includes(food.category.id);
const matchesFood = hasFoodRestriction && (coupon.foods ?? []).includes(food.id);
if (matchesCategory || matchesFood) {
const unitPrice = Number(item.price) || 0;
const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice);
eligibleItemsTotal += unitPrice * item.quantity;
eligibleItemsDiscount += unitDiscount * item.quantity;
}
}
}
const priceAfterItemDiscount = Math.max(0, eligibleItemsTotal - eligibleItemsDiscount);
if (cart.coupon.type === CouponType.PERCENTAGE) {
let discount = (priceAfterItemDiscount * cart.coupon.value) / 100;
if (cart.coupon.maxDiscount && discount > cart.coupon.maxDiscount) {
discount = cart.coupon.maxDiscount;
}
return discount;
}
return Math.min(cart.coupon.value, priceAfterItemDiscount);
}
/**
* Calculate tax
*/
async calculateTax(restaurantId: string, amountAfterDiscounts: number): Promise<number> {
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
const vat = restaurant?.vat ? Number(restaurant.vat) : 0;
if (!vat || vat <= 0) return 0;
return (Math.max(0, amountAfterDiscounts) * vat) / 100;
}
/**
* Calculate delivery fee
*/
async calculateDeliveryFee(cart: Cart): Promise<number> {
const deliveryMethodId = cart.deliveryMethodId;
if (!deliveryMethodId) return 0;
const deliveryMethod = await this.em.findOne(Delivery, { id: deliveryMethodId }, { populate: ['restaurant'] });
if (!deliveryMethod || !deliveryMethod.enabled) return 0;
// If not distance based, return fixed delivery fee
if (deliveryMethod.deliveryFeeType !== DeliveryFeeTypeEnum.DISTANCE_BASED) {
return Number(deliveryMethod.deliveryFee) || 0;
}
// For distance based calculation we need restaurant and user coordinates
const restaurant = (deliveryMethod as any).restaurant as Restaurant | undefined;
const userAddr = cart.userAddress;
if (!restaurant || restaurant.latitude == null || restaurant.longitude == null) {
// fallback to configured fixed fee when coordinates are missing
return Number(deliveryMethod.deliveryFee) || 0;
}
if (!userAddr || userAddr.latitude == null || userAddr.longitude == null) {
return Number(deliveryMethod.deliveryFee) || 0;
}
const restLat = Number(restaurant.latitude);
const restLng = Number(restaurant.longitude);
const userLat = Number(userAddr.latitude);
const userLng = Number(userAddr.longitude);
const distanceKm = GeographicUtils.getDistanceKmRounded(restLat, restLng, userLat, userLng);
// Try to read either possible property names (legacy vs new)
const perKm = Number(deliveryMethod.perKilometerFee);
const minFee = Number(deliveryMethod.distanceBasedMinCost);
let fee = 0;
if (perKm <= 0) {
fee = Number(deliveryMethod.deliveryFee) || 0;
} else {
fee = distanceKm * perKm;
}
if (minFee > 0 && fee < minFee) fee = minFee;
return Math.max(0, Number(fee));
}
/**
* Recalculate cart totals (including coupon discount and tax)
*/
async recalculateCartTotals(cart: Cart): Promise<void> {
const { subTotal, itemsDiscount, totalItems } = this.calculateItemsTotals(cart);
cart.subTotal = subTotal;
cart.itemsDiscount = itemsDiscount;
const couponDiscount = await this.calculateCouponDiscount(cart, subTotal, itemsDiscount);
cart.couponDiscount = couponDiscount;
cart.totalDiscount = couponDiscount + itemsDiscount;
cart.tax = await this.calculateTax(cart.restaurantId, Math.max(0, subTotal - cart.totalDiscount));
cart.deliveryFee = await this.calculateDeliveryFee(cart);
// total = subtotal totalDiscount + tax + deliveryFee
cart.total = Math.max(0, subTotal - cart.totalDiscount) + cart.tax + cart.deliveryFee;
cart.totalItems = totalItems;
cart.updatedAt = this.nowIso();
}
/**
* Get coupon restrictions or clear if invalid
*/
private async getCouponRestrictionsOrClear(cart: Cart): Promise<{
isActive?: boolean;
startDate?: Date;
endDate?: Date;
foodCategories?: string[];
foods?: string[];
} | null> {
if (!cart.coupon) return null;
try {
const c = await this.couponService.findById(cart.coupon.couponId);
const now = new Date();
if (c.isActive === false) {
cart.coupon = undefined;
return null;
}
if (c.startDate && now < c.startDate) {
cart.coupon = undefined;
return null;
}
if (c.endDate && now > c.endDate) {
cart.coupon = undefined;
return null;
}
return {
isActive: c.isActive,
startDate: c.startDate,
endDate: c.endDate,
foodCategories: c.foodCategories,
foods: c.foods,
};
} catch {
cart.coupon = undefined;
return null;
}
}
/**
* Get foods in cart with categories
*/
private async getFoodsInCartWithCategories(cart: Cart): Promise<Map<string, Food>> {
const foodIds = cart.items.map(item => item.foodId);
if (foodIds.length === 0) return new Map();
const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['category'] });
return new Map(foods.map(f => [f.id, f]));
}
/**
* Get current ISO timestamp
*/
private nowIso(): string {
return new Date().toISOString();
}
}
@@ -0,0 +1,113 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Food } from '../../foods/entities/food.entity';
import { Cart, CartItem } from '../interfaces/cart.interface';
import { CartValidationService } from './cart-validation.service';
import { CartCalculationService } from './cart-calculation.service';
import { CartMessage } from 'src/common/enums/message.enum';
@Injectable()
export class CartItemService {
constructor(
private readonly validationService: CartValidationService,
private readonly calculationService: CartCalculationService,
) {}
/**
* Create a new cart item from food
*/
createCartItem(food: Food, quantity: number): CartItem {
const itemPrice = food.price || 0;
const itemDiscount = food.discount || 0;
return {
foodId: food.id,
foodTitle: food.title,
quantity,
price: itemPrice,
discount: itemDiscount,
totalPrice: this.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, quantity),
};
}
/**
* Build cart item from food (updating existing item if provided)
*/
buildCartItemFromFood(food: Food, quantity: number, previous?: CartItem): CartItem {
const itemPrice = food.price || 0;
const itemDiscount = food.discount || 0;
return {
...(previous ?? ({} as CartItem)),
foodId: food.id,
foodTitle: food.title,
quantity,
price: itemPrice,
discount: itemDiscount,
totalPrice: this.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, quantity),
};
}
/**
* Get item index in cart
*/
getItemIndex(cart: Cart, foodId: string): number {
return cart.items.findIndex(item => item.foodId === foodId);
}
/**
* Add or increment item in cart
*/
async addOrIncrementItem(cart: Cart, foodId: string, restaurantId: string, quantityToAdd: number): Promise<void> {
const food = await this.validationService.validateAndGetFood(foodId, restaurantId, quantityToAdd);
// Validate meal type compatibility
this.validationService.assertMealTypeCompatibility(food);
// Validate weekday compatibility
this.validationService.assertWeekdayCompatibility(food);
const index = this.getItemIndex(cart, food.id);
if (index < 0) {
cart.items.push(this.createCartItem(food, quantityToAdd));
return;
}
const existingItem = cart.items[index];
const newQuantity = existingItem.quantity + quantityToAdd;
this.validationService.validateStock(food, newQuantity);
cart.items[index] = this.buildCartItemFromFood(food, newQuantity, existingItem);
}
/**
* Remove item from cart
*/
removeItemOrFail(cart: Cart, foodId: string): void {
const itemIndex = this.getItemIndex(cart, foodId);
if (itemIndex < 0) {
throw new NotFoundException(CartMessage.ITEM_NOT_FOUND);
}
cart.items.splice(itemIndex, 1);
}
/**
* Decrement item quantity or remove if quantity reaches 0
*/
async decrementOrRemoveItem(cart: Cart, foodId: string): Promise<void> {
const itemIndex = this.getItemIndex(cart, foodId);
if (itemIndex < 0) {
throw new NotFoundException(CartMessage.ITEM_NOT_FOUND);
}
const existingItem = cart.items[itemIndex];
const newQuantity = existingItem.quantity - 1;
if (newQuantity <= 0) {
cart.items.splice(itemIndex, 1);
return;
}
const food = await this.validationService.getFoodOrFail(foodId);
cart.items[itemIndex] = this.buildCartItemFromFood(food, newQuantity, existingItem);
}
}
@@ -0,0 +1,422 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { Food } from '../../foods/entities/food.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { UserAddress } from '../../users/entities/user-address.entity';
import { User } from '../../users/entities/user.entity';
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
import { Delivery } from '../../delivery/entities/delivery.entity';
import { DeliveryMethodEnum } from '../../delivery/interface/delivery';
import { PointTransaction } from '../../users/entities/point-transaction.entity';
import { Order } from '../../orders/entities/order.entity';
import { OrderStatus } from '../../orders/interface/order.interface';
import { Cart } from '../interfaces/cart.interface';
import { GeographicUtils } from '../utils/geographic.utils';
import { MealType } from '../../foods/interface/food.interface';
import { CartMessage } from 'src/common/enums/message.enum';
import { WalletTransactionRepository } from 'src/modules/users/repositories/wallet-transaction.repository';
@Injectable()
export class CartValidationService {
constructor(
private readonly em: EntityManager,
private readonly walletTransactionRepository: WalletTransactionRepository,
) { }
/**
* Validate food exists, belongs to restaurant, has inventory, and check stock
*/
async validateAndGetFood(foodId: string, restaurantId: string, quantity: number): Promise<Food> {
const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant', 'inventory'] });
if (!food) {
throw new NotFoundException(CartMessage.FOOD_NOT_FOUND);
}
if (food.restaurant.id !== restaurantId) {
throw new BadRequestException(CartMessage.FOOD_NOT_BELONGS_TO_RESTAURANT);
}
if (!food.inventory) {
throw new BadRequestException(CartMessage.FOOD_NO_INVENTORY);
}
this.validateStock(food, quantity);
return food;
}
/**
* Validate stock availability for food
*/
validateStock(food: Food, quantity: number): void {
const availableStock = food.inventory!.availableStock;
if (availableStock < quantity) {
throw new BadRequestException(CartMessage.INSUFFICIENT_STOCK + food.title);
}
}
/**
* Get user or throw if not found
*/
async getUserOrFail(userId: string): Promise<User> {
const user = await this.em.findOne(User, { id: userId });
if (!user) {
throw new NotFoundException(CartMessage.USER_NOT_FOUND);
}
return user;
}
/**
* Get user address or throw if not found
*/
async getUserAddressOrFail(addressId: string): Promise<UserAddress> {
const address = await this.em.findOne(UserAddress, { id: addressId }, { populate: ['user'] });
if (!address) {
throw new NotFoundException(CartMessage.ADDRESS_NOT_FOUND);
}
return address;
}
/**
* Get food or throw if not found
*/
async getFoodOrFail(foodId: string): Promise<Food> {
const food = await this.em.findOne(Food, { id: foodId });
if (!food) {
throw new NotFoundException(CartMessage.FOOD_NOT_FOUND);
}
return food;
}
/**
* Get restaurant or throw if not found
*/
async getRestaurantOrFail(restaurantId: string): Promise<Restaurant> {
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
if (!restaurant) {
throw new NotFoundException(CartMessage.RESTAURANT_NOT_FOUND);
}
return restaurant;
}
/**
* Validate address belongs to user
*/
validateAddressOwnership(address: UserAddress, userId: string): void {
if (address.user.id !== userId) {
throw new BadRequestException(CartMessage.ADDRESS_NOT_BELONGS_TO_USER);
}
}
/**
* Assert address is inside restaurant service area
*/
async assertAddressInsideServiceArea(
restaurantId: string,
latitude?: number | null,
longitude?: number | null,
): Promise<void> {
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES);
}
const restaurant = await this.getRestaurantOrFail(restaurantId);
const serviceArea = restaurant.serviceArea;
// If no service area is defined, assume service is available everywhere
if (!serviceArea || !serviceArea.coordinates || !Array.isArray(serviceArea.coordinates)) return;
// GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring
const rings = serviceArea.coordinates;
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
const ring = rings[0];
const point: [number, number] = [Number(longitude), Number(latitude)];
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
const polygon: [number, number][] = ring.map(
coord => [Number(coord[0] ?? 0), Number(coord[1] ?? 0)] as [number, number],
);
if (!GeographicUtils.isPointInPolygon(point, polygon)) {
throw new BadRequestException(CartMessage.ADDRESS_OUTSIDE_SERVICE_AREA);
}
}
/**
* Get delivery method for restaurant or throw if not found
*/
async getDeliveryMethodForRestaurantOrFail(
restaurantId: string,
deliveryMethodId: string,
): Promise<Delivery> {
const deliveryMethod = await this.em.findOne(Delivery, {
id: deliveryMethodId,
restaurant: { id: restaurantId },
});
if (!deliveryMethod) {
throw new NotFoundException(CartMessage.DELIVERY_METHOD_NOT_FOUND_FOR_RESTAURANT);
}
return deliveryMethod;
}
/**
* Get enabled delivery method or throw if not found or disabled
*/
async getEnabledDeliveryMethodOrFail(restaurantId: string, deliveryMethodId: string): Promise<Delivery> {
const deliveryMethod = await this.em.findOne(
Delivery,
{ id: deliveryMethodId, restaurant: { id: restaurantId } },
{ populate: ['restaurant'] },
);
if (!deliveryMethod) {
throw new NotFoundException(CartMessage.DELIVERY_METHOD_NOT_FOUND);
}
if (!deliveryMethod.enabled) {
throw new BadRequestException(CartMessage.DELIVERY_METHOD_NOT_ENABLED);
}
return deliveryMethod;
}
/**
* Assert delivery method matches expected type
*/
assertDeliveryMethod(actual: DeliveryMethodEnum, expected: DeliveryMethodEnum, message: string): void {
if (actual !== expected) {
throw new BadRequestException(message);
}
}
/**
* Require delivery method ID or throw
*/
requireDeliveryMethodId(cart: Cart, message: string): string {
if (!cart.deliveryMethodId) {
throw new BadRequestException(message);
}
return cart.deliveryMethodId;
}
/**
* Get enabled payment method or throw if not found or disabled
*/
async getEnabledPaymentMethodOrFail(restaurantId: string, paymentMethodId: string): Promise<PaymentMethod> {
const paymentMethod = await this.em.findOne(
PaymentMethod,
{ id: paymentMethodId, restaurant: { id: restaurantId } },
{ populate: ['restaurant'] },
);
if (!paymentMethod) {
throw new NotFoundException(CartMessage.PAYMENT_METHOD_NOT_FOUND);
}
if (!paymentMethod.enabled) {
throw new BadRequestException(CartMessage.PAYMENT_METHOD_NOT_ENABLED);
}
return paymentMethod;
}
/**
* Assert wallet has enough balance
*/
async assertWalletHasEnoughBalance(userId: string, restaurantId: string, amount: number): Promise<void> {
const balance = await this.walletTransactionRepository.getCurrentWalletBalance(userId, restaurantId);
if (balance < amount) {
throw new BadRequestException(CartMessage.WALLET_INSUFFICIENT);
}
}
/**
* Assert coupon usage limit
*/
async assertCouponUsageLimit(userId: string, couponId: string, maxUsesPerUser?: number): Promise<void> {
if (!maxUsesPerUser) return;
const ordersThatUsedTheCouponCount = await this.em.count(Order, {
user: { id: userId },
couponDetail: { couponId: couponId },
status: { $ne: OrderStatus.CANCELED },
deletedAt: null,
});
if (ordersThatUsedTheCouponCount >= maxUsesPerUser) {
throw new BadRequestException(CartMessage.COUPON_MAX_USES_REACHED);
}
}
/**
* Assert coupon matches cart if restricted
*/
async assertCouponMatchesCartIfRestricted(
cart: Cart,
foodCategories?: string[] | null,
foods?: string[] | null,
): Promise<void> {
const categoryRestriction = foodCategories?.filter(Boolean) ?? [];
const foodRestriction = foods?.filter(Boolean) ?? [];
const hasCategoryRestriction = categoryRestriction.length > 0;
const hasFoodRestriction = foodRestriction.length > 0;
if (!hasCategoryRestriction && !hasFoodRestriction) return;
const foodMap = await this.getFoodsInCartWithCategories(cart);
for (const item of cart.items) {
const food = foodMap.get(item.foodId);
if (!food) continue;
const matchesCategory = hasCategoryRestriction && food.category && categoryRestriction.includes(food.category.id);
const matchesFood = hasFoodRestriction && foodRestriction.includes(food.id);
if (matchesCategory || matchesFood) return;
}
throw new BadRequestException(CartMessage.COUPON_CANNOT_BE_APPLIED);
}
/**
* Get foods in cart with categories
*/
async getFoodsInCartWithCategories(cart: Cart): Promise<Map<string, Food>> {
const foodIds = cart.items.map(item => item.foodId);
if (foodIds.length === 0) return new Map();
const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['category'] });
return new Map(foods.map(f => [f.id, f]));
}
/**
* Assert all foods in cart have pickupServe true when delivery method is courier
*/
async assertAllFoodsHavePickupServeForCourier(cart: Cart, deliveryMethod: DeliveryMethodEnum): Promise<void> {
if (deliveryMethod !== DeliveryMethodEnum.DeliveryCourier) {
return;
}
if (cart.items.length === 0) {
return;
}
const foodIds = cart.items.map(item => item.foodId);
const foods = await this.em.find(Food, { id: { $in: foodIds } });
const foodsWithoutPickupServe = foods.filter(food => !food.pickupServe);
if (foodsWithoutPickupServe.length > 0) {
const foodTitles = foodsWithoutPickupServe.map(f => f.title || f.id).join(', ');
throw new BadRequestException(
CartMessage.FOODS_MUST_HAVE_PICKUP_SERVE_FOR_COURIER.replace('[foodTitles]', foodTitles),
);
}
}
/**
* Get current Iran time context (weekday and meal type)
*/
private getCurrentIranTimeContext(): { iranWeekDay: number; mealType: MealType | null } {
const nowInIran = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Tehran' }));
const weekDay = nowInIran.getDay(); // 0 = Sunday, 6 = Saturday
const currentHour = nowInIran.getHours();
// Convert to Iran weekday: Saturday=0, Sunday=1, ..., Friday=6
// JavaScript: Sunday=0, Monday=1, ..., Saturday=6
// Iran week: Saturday=0, Sunday=1, ..., Friday=6
const iranWeekDay = (weekDay + 1) % 7;
const mealType = this.getMealTypeByHour(currentHour);
return { iranWeekDay, mealType };
}
/**
* Determine meal type based on current hour in Iran timezone.
* @param hour - Current hour (0-23)
* @returns Meal type or null if outside meal hours
*/
private getMealTypeByHour(hour: number): MealType | null {
const MEAL_TIME_RANGES = {
BREAKFAST: { start: 6, end: 11 },
LUNCH: { start: 11, end: 15 },
SNACK: { start: 15, end: 19 },
DINNER: { start: 19, end: 24 },
} as const;
if (hour >= MEAL_TIME_RANGES.BREAKFAST.start && hour < MEAL_TIME_RANGES.BREAKFAST.end) {
return MealType.BREAKFAST;
}
if (hour >= MEAL_TIME_RANGES.LUNCH.start && hour < MEAL_TIME_RANGES.LUNCH.end) {
return MealType.LUNCH;
}
if (hour >= MEAL_TIME_RANGES.SNACK.start && hour < MEAL_TIME_RANGES.SNACK.end) {
return MealType.SNACK;
}
if (hour >= MEAL_TIME_RANGES.DINNER.start && hour < MEAL_TIME_RANGES.DINNER.end) {
return MealType.DINNER;
}
return null;
}
/**
* Assert meal type compatibility - if food has mealTypes, current meal time must be in that array
*/
assertMealTypeCompatibility(food: Food): void {
// If food has no meal type restrictions, allow it
if (!food.mealTypes || food.mealTypes.length === 0) {
return;
}
const { mealType } = this.getCurrentIranTimeContext();
// If current time is outside meal hours, throw error
if (mealType === null) {
const foodTitle = food.title || food.id;
throw new BadRequestException(
CartMessage.FOOD_ONLY_AVAILABLE_DURING_MEAL_TIMES.replace('[foodTitle]', foodTitle),
);
}
// Check if current meal type is in food's allowed meal types
if (!food.mealTypes.includes(mealType)) {
const foodTitle = food.title || food.id;
const mealTypeMap: Record<MealType, string> = {
[MealType.BREAKFAST]: 'صبحانه',
[MealType.LUNCH]: 'ناهار',
[MealType.SNACK]: 'عصرانه',
[MealType.DINNER]: 'شام',
};
const allowedMealTypes = food.mealTypes.map(mt => mealTypeMap[mt]).join(', ');
const currentMealType = mealType ? mealTypeMap[mealType] : mealType;
throw new BadRequestException(
CartMessage.FOOD_ONLY_AVAILABLE_FOR_MEAL_TYPES.replace('[foodTitle]', foodTitle)
.replace('[allowedMealTypes]', allowedMealTypes)
.replace('[mealType]', currentMealType),
);
}
}
/**
* Assert weekday compatibility - current weekday must be in food's weekDays array
*/
assertWeekdayCompatibility(food: Food): void {
// If food has no weekday restrictions (empty array or all days), allow it
if (!food.weekDays || food.weekDays.length === 0 || food.weekDays.length === 7) {
return;
}
const { iranWeekDay } = this.getCurrentIranTimeContext();
// Check if current weekday is in food's allowed weekdays
if (!food.weekDays.includes(iranWeekDay)) {
const dayNames = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه'];
const currentDayName = dayNames[iranWeekDay];
const allowedDays = food.weekDays.map(day => dayNames[day]).join(', ');
const foodTitle = food.title || food.id;
throw new BadRequestException(
CartMessage.FOOD_ONLY_AVAILABLE_ON_DAYS.replace('[foodTitle]', foodTitle)
.replace('[allowedDays]', allowedDays)
.replace('[currentDay]', currentDayName),
);
}
}
}
+487
View File
@@ -0,0 +1,487 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { DeliveryMethodEnum } from '../../delivery/interface/delivery';
import { PaymentMethodEnum } from '../../payments/interface/payment';
import { OrderCouponDetail } from '../../orders/interface/order.interface';
import { Cart } from '../interfaces/cart.interface';
import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto';
import { ApplyCouponDto } from '../dto/apply-coupon.dto';
import { SetAllCartParmsDto } from '../dto/set-all-cart-params.dto';
import { CouponService } from '../../coupons/providers/coupon.service';
import { CartRepository } from '../repositories/cart.repository';
import { CartValidationService } from './cart-validation.service';
import { CartCalculationService } from './cart-calculation.service';
import { CartItemService } from './cart-item.service';
import { SetCarDeliveryDto } from '../dto/set-car-delivery.dto';
import { CartMessage } from 'src/common/enums/message.enum';
@Injectable()
export class CartService {
constructor(
private readonly cartRepository: CartRepository,
private readonly validationService: CartValidationService,
private readonly calculationService: CartCalculationService,
private readonly itemService: CartItemService,
private readonly couponService: CouponService,
) { }
/**
* Set all cart parameters at once
*/
async setAllCartParams(userId: string, restaurantId: string, params: SetAllCartParmsDto): Promise<Cart> {
const { deliveryMethodId, paymentMethodId, addressId, carAddress, description, tableNumber } = params;
// get existing cart (or throw)
const cart = await this.findOneOrFail(userId, restaurantId);
// Validate and get delivery method
const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail(
restaurantId,
deliveryMethodId,
);
cart.deliveryMethodId = deliveryMethodId;
// Handle each delivery method with its specific requirements
switch (deliveryMethod.method) {
case DeliveryMethodEnum.DineIn:
// DineIn requires table number and clears incompatible fields
if (!tableNumber) {
throw new BadRequestException(CartMessage.TABLE_NUMBER_REQUIRED);
}
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn);
cart.tableNumber = tableNumber;
break;
case DeliveryMethodEnum.CustomerPickup:
// CustomerPickup just clears incompatible fields
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.CustomerPickup);
break;
case DeliveryMethodEnum.DeliveryCar:
// DeliveryCar requires carAddress and clears incompatible fields
if (!carAddress) {
throw new BadRequestException(CartMessage.CAR_ADDRESS_REQUIRED);
}
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar);
const user = await this.validationService.getUserOrFail(userId);
cart.carAddress = {
carModel: carAddress.carModel,
carColor: carAddress.carColor,
plateNumber: carAddress.plateNumber,
phone: user.phone,
};
break;
case DeliveryMethodEnum.DeliveryCourier:
// DeliveryCourier requires addressId and clears incompatible fields
if (!addressId) {
throw new BadRequestException(CartMessage.ADDRESS_REQUIRED);
}
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier);
const address = await this.validationService.getUserAddressOrFail(addressId);
this.validationService.validateAddressOwnership(address, userId);
// ensure address is within restaurant service area
await this.validationService.assertAddressInsideServiceArea(
restaurantId,
address.latitude,
address.longitude,
);
cart.userAddress = {
address: address.address,
latitude: address.latitude,
longitude: address.longitude,
city: address.city,
province: address.province || '',
postalCode: address.postalCode || undefined,
fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(),
phone: address.user.phone,
};
break;
}
// Validate and set payment method
const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail(
restaurantId,
paymentMethodId,
);
// Recalculate totals first so wallet check uses up-to-date total (delivery method may have changed above).
await this.calculationService.recalculateCartTotals(cart);
if (paymentMethod.method === PaymentMethodEnum.Wallet) {
await this.validationService.assertWalletHasEnoughBalance(userId, restaurantId, cart.total);
}
cart.paymentMethodId = paymentMethodId;
// Set description (if provided)
if (description !== undefined) {
cart.description = description;
}
// Final validation: if effective delivery method is courier, ensure all foods have pickupServe
await this.validationService.assertAllFoodsHavePickupServeForCourier(cart, deliveryMethod.method);
// Final recalculation + save and return cart
return this.recalculateAndSaveCart(cart);
}
/**
* Get or create cart for user and restaurant
*/
async getOrCreateCart(userId: string, restaurantId: string): Promise<Cart> {
const existingCart = await this.cartRepository.findByUserAndRestaurant(userId, restaurantId);
if (existingCart) {
return existingCart;
}
// Find restaurant
const restaurant = await this.validationService.getRestaurantOrFail(restaurantId);
// Create new cart
const now = this.nowIso();
const cart: Cart = {
userId,
restaurantId,
restaurantName: restaurant.name,
items: [],
deliveryFee: 0,
subTotal: 0,
itemsDiscount: 0,
couponDiscount: 0,
totalDiscount: 0,
tax: 0,
total: 0,
totalItems: 0,
createdAt: now,
updatedAt: now,
};
await this.cartRepository.save(cart);
return cart;
}
/**
* Find cart by user and restaurant
*/
async findOneOrFail(userId: string, restaurantId: string): Promise<Cart> {
const cart = await this.cartRepository.findByUserAndRestaurant(userId, restaurantId);
if (!cart) {
throw new NotFoundException(CartMessage.NOT_FOUND);
}
return cart;
}
/**
* Increment item quantity in cart
*/
async incrementItem(userId: string, restaurantId: string, foodId: string, quantity: number): Promise<Cart> {
const cart = await this.getOrCreateCart(userId, restaurantId);
await this.itemService.addOrIncrementItem(cart, foodId, restaurantId, quantity);
return this.recalculateAndSaveCart(cart);
}
/**
* Decrement item quantity in cart by 1 (removes item if quantity reaches 0)
*/
async decrementItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
await this.itemService.decrementOrRemoveItem(cart, foodId);
return this.recalculateAndSaveCart(cart);
}
/**
* Bulk add items to cart (increments quantity if items exist)
*/
async bulkAddItems(userId: string, restaurantId: string, bulkAddItemsDto: BulkAddItemsToCartDto): Promise<Cart> {
const cart = await this.getOrCreateCart(userId, restaurantId);
for (const addItemDto of bulkAddItemsDto.items) {
await this.itemService.addOrIncrementItem(cart, addItemDto.foodId, restaurantId, addItemDto.quantity);
}
return this.recalculateAndSaveCart(cart);
}
/**
* Remove item from cart
*/
async removeItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
this.itemService.removeItemOrFail(cart, foodId);
return this.recalculateAndSaveCart(cart);
}
/**
* Apply coupon to cart
*/
async applyCoupon(userId: string, restaurantId: string, applyCouponDto: ApplyCouponDto): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
const orderAmount = cart.subTotal - cart.itemsDiscount;
const user = await this.validationService.getUserOrFail(userId);
// check if coupon is valid and belong to the restaurant
const { valid, coupon, message } = await this.couponService.validateCoupon(
applyCouponDto.code,
restaurantId,
Number(orderAmount),
user.phone,
);
if (!valid) {
throw new BadRequestException(message || CartMessage.COUPON_NOT_FOUND);
}
if (!coupon) {
throw new BadRequestException(CartMessage.COUPON_NOT_FOUND);
}
// Check maxUsesPerUser limit by counting how many orders this user has with this coupon
await this.validationService.assertCouponUsageLimit(userId, coupon.id, coupon.maxUsesPerUser);
await this.validationService.assertCouponMatchesCartIfRestricted(
cart,
coupon.foodCategories,
coupon.foods,
);
const couponDetail: OrderCouponDetail = {
couponId: coupon.id,
couponName: coupon.name,
couponCode: coupon.code,
type: coupon.type,
value: coupon.value,
maxDiscount: coupon.maxDiscount,
};
cart.coupon = couponDetail;
return this.recalculateAndSaveCart(cart);
}
/**
* Remove coupon from cart
*/
async removeCoupon(userId: string, restaurantId: string): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
cart.coupon = undefined;
return this.recalculateAndSaveCart(cart);
}
/**
* Set address for cart
*/
async setAddress(userId: string, restaurantId: string, addressId: string): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
cart,
'Delivery method must be set before setting address',
);
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
restaurantId,
deliveryMethodId,
);
this.validationService.assertDeliveryMethod(
deliveryMethod.method,
DeliveryMethodEnum.DeliveryCourier,
'Delivery method must be DeliveryCourier',
);
// Find and validate address belongs to user
const address = await this.validationService.getUserAddressOrFail(addressId);
// Verify address belongs to the user
this.validationService.validateAddressOwnership(address, userId);
// ensure address is within restaurant service area
await this.validationService.assertAddressInsideServiceArea(
restaurantId,
address.latitude,
address.longitude,
);
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier);
cart.userAddress = {
address: address.address,
latitude: address.latitude,
longitude: address.longitude,
city: address.city,
province: address.province || '',
postalCode: address.postalCode || undefined,
fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(),
phone: address.user.phone,
};
return this.saveTouchedCart(cart);
}
/**
* Set payment method for cart
*/
async setPaymentMethod(
userId: string,
restaurantId: string,
paymentMethodId: string,
): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail(
restaurantId,
paymentMethodId,
);
if (paymentMethod.method === PaymentMethodEnum.Wallet) {
await this.validationService.assertWalletHasEnoughBalance(userId, restaurantId, cart.total);
}
cart.paymentMethodId = paymentMethodId;
return this.recalculateAndSaveCart(cart);
}
/**
* Set delivery method for cart
*/
async setDeliveryMethod(
userId: string,
restaurantId: string,
deliveryMethodId: string,
): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail(
restaurantId,
deliveryMethodId,
);
cart.deliveryMethodId = deliveryMethodId;
this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method);
return this.recalculateAndSaveCart(cart);
}
/**
* Set description for cart
*/
async setDescription(userId: string, restaurantId: string, description: string): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
cart.description = description;
return this.saveTouchedCart(cart);
}
/**
* Set table number for cart
*/
async setTableNumber(userId: string, restaurantId: string, tableNumber: string): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
cart,
'Delivery method must be set before setting table number',
);
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
restaurantId,
deliveryMethodId,
);
this.validationService.assertDeliveryMethod(
deliveryMethod.method,
DeliveryMethodEnum.DineIn,
'Delivery method must be DineIn',
);
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn);
cart.tableNumber = tableNumber;
return this.saveTouchedCart(cart);
}
/**
* Set car delivery for cart
*/
async setCarDelivery(userId: string, restaurantId: string, setCarDeliveryDto: SetCarDeliveryDto): Promise<Cart> {
const cart = await this.findOneOrFail(userId, restaurantId);
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
cart,
'Delivery method must be set before setting car delivery',
);
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
restaurantId,
deliveryMethodId,
);
this.validationService.assertDeliveryMethod(
deliveryMethod.method,
DeliveryMethodEnum.DeliveryCar,
'Delivery method must be DeliveryCar',
);
const user = await this.validationService.getUserOrFail(userId);
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar);
cart.carAddress = {
carModel: setCarDeliveryDto.carModel,
carColor: setCarDeliveryDto.carColor,
plateNumber: setCarDeliveryDto.plateNumber,
phone: user.phone,
};
return this.saveTouchedCart(cart);
}
/**
* Clear cart from cache
*/
async clearCart(userId: string, restaurantId: string): Promise<void> {
return this.cartRepository.delete(userId, restaurantId);
}
/**
* Clears cart fields that are not applicable for a given delivery method.
*/
private clearFieldsIncompatibleWithDeliveryMethod(cart: Cart, method: DeliveryMethodEnum): void {
if (method === DeliveryMethodEnum.DineIn) {
cart.userAddress = null;
cart.carAddress = null;
return;
}
if (method === DeliveryMethodEnum.CustomerPickup) {
cart.userAddress = null;
cart.carAddress = null;
cart.tableNumber = undefined;
return;
}
if (method === DeliveryMethodEnum.DeliveryCourier) {
cart.carAddress = null;
cart.tableNumber = undefined;
return;
}
if (method === DeliveryMethodEnum.DeliveryCar) {
cart.userAddress = null;
cart.tableNumber = undefined;
return;
}
}
/**
* Save cart with updated timestamp
*/
private async saveTouchedCart(cart: Cart): Promise<Cart> {
cart.updatedAt = this.nowIso();
await this.cartRepository.save(cart);
return cart;
}
/**
* Recalculate cart totals and save
*/
private async recalculateAndSaveCart(cart: Cart): Promise<Cart> {
await this.calculationService.recalculateCartTotals(cart);
await this.cartRepository.save(cart);
return cart;
}
/**
* Get current ISO timestamp
*/
private nowIso(): string {
return new Date().toISOString();
}
}
@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from '../../utils/cache.service';
import { Cart } from '../interfaces/cart.interface';
@Injectable()
export class CartRepository {
private readonly CART_TTL = 3600; // 1 hour in seconds
private readonly CART_KEY_PREFIX = 'cart';
constructor(private readonly cacheService: CacheService) {}
/**
* Get cart by user and restaurant
*/
async findByUserAndRestaurant(userId: string, restaurantId: string): Promise<Cart | null> {
const cacheKey = this.getCacheKey(userId, restaurantId);
const cachedCart = await this.cacheService.get<string>(cacheKey);
if (cachedCart) {
try {
const parsed: unknown = JSON.parse(cachedCart);
if (this.isCart(parsed) && parsed.userId === userId && parsed.restaurantId === restaurantId) {
return parsed;
}
} catch {
// If parsing fails, return null
}
}
return null;
}
/**
* Save cart to cache
*/
async save(cart: Cart): Promise<void> {
const cacheKey = this.getCacheKey(cart.userId, cart.restaurantId);
await this.cacheService.set(cacheKey, JSON.stringify(cart), this.CART_TTL);
}
/**
* Delete cart from cache
*/
async delete(userId: string, restaurantId: string): Promise<void> {
const cacheKey = this.getCacheKey(userId, restaurantId);
await this.cacheService.del(cacheKey);
}
/**
* Generate cache key for cart
*/
private getCacheKey(userId: string, restaurantId: string): string {
return `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`;
}
/**
* Type guard to check if an object is a Cart
*/
private isCart(obj: unknown): obj is Cart {
if (typeof obj !== 'object' || obj === null) {
return false;
}
const cart = obj as Record<string, unknown>;
return (
typeof cart.userId === 'string' &&
typeof cart.restaurantId === 'string' &&
Array.isArray(cart.items) &&
typeof cart.subTotal === 'number' &&
typeof cart.itemsDiscount === 'number' &&
typeof cart.couponDiscount === 'number' &&
typeof cart.totalDiscount === 'number' &&
typeof cart.tax === 'number' &&
typeof cart.deliveryFee === 'number' &&
typeof cart.total === 'number' &&
typeof cart.totalItems === 'number' &&
typeof cart.createdAt === 'string' &&
typeof cart.updatedAt === 'string'
);
}
}
@@ -0,0 +1,68 @@
/**
* Utility class for geographic calculations
*/
export class GeographicUtils {
/**
* Check if a point is inside a polygon using ray-casting algorithm
* @param point - [longitude, latitude]
* @param polygon - Array of [longitude, latitude] pairs
*/
static isPointInPolygon(point: [number, number], polygon: [number, number][]): boolean {
const x = point[0];
const y = point[1];
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i][0];
const yi = polygon[i][1];
const xj = polygon[j][0];
const yj = polygon[j][1];
const intersect =
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi + Number.EPSILON) + xi;
if (intersect) inside = !inside;
}
return inside;
}
/**
* Convert degrees to radians
*/
private static toRad(value: number): number {
return (value * Math.PI) / 180;
}
/**
* Calculate distance between two points in kilometers using Haversine formula
*/
static getDistanceKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371; // Earth radius in KM
const dLat = this.toRad(lat2 - lat1);
const dLng = this.toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLng / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Calculate distance between two points in kilometers (rounded)
*/
static getDistanceKmRounded(
lat1: number,
lng1: number,
lat2: number,
lng2: number,
precision = 2,
): number {
const distance = this.getDistanceKm(lat1, lng1, lat2, lng2);
return Number(distance.toFixed(precision));
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ContactService } from './providers/contact.service';
import { ContactController } from './controllers/contact.controller';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Contact } from './entities/contact.entity';
import { ContactRepository } from './repositories/contact.repository';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [MikroOrmModule.forFeature([Contact]), JwtModule],
controllers: [ContactController],
providers: [ContactService, ContactRepository],
exports: [ContactRepository],
})
export class ContactModule {}
@@ -0,0 +1,108 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Query,
UseGuards,
Delete,
} from '@nestjs/common';
import { ContactService } from '../providers/contact.service';
import { CreateContactDto } from '../dto/create-contact.dto';
import { FindContactsDto } from '../dto/find-contacts.dto';
import { UpdateContactStatusDto } from '../dto/update-contact-status.dto';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiHeader } from '@nestjs/swagger';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
import { OptionalAuthGuard } from 'src/modules/auth/guards/optinalAuth.guard';
import { API_HEADER_SLUG } from 'src/common/constants';
import { Permission } from 'src/common/enums/permission.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { RestId } from 'src/common/decorators/rest-id.decorator';
import { RestSlug } from 'src/common/decorators/rest-slug.decorator';
@ApiTags('contact')
@Controller()
export class ContactController {
constructor(private readonly contactService: ContactService) { }
@UseGuards(OptionalAuthGuard)
@Post('public/contact')
@ApiOperation({ summary: 'Create a new contact (public endpoint)' })
@ApiHeader(API_HEADER_SLUG)
async create(@Body() createContactDto: CreateContactDto, @RestSlug() slug: string, @RestId() restId?: string) {
return this.contactService.create(createContactDto, restId, slug);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_CONTACTS)
@Get('admin/contact')
@ApiOperation({ summary: 'Get paginated list of contacts (admin only)' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
@ApiQuery({ name: 'status', required: false, enum: ['new', 'seen'] })
async findAllPaginated(@RestId() restId: string, @Query() dto: FindContactsDto) {
return this.contactService.findAllPaginatedAdmin(dto, restId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_CONTACTS)
@Get('admin/contact/:id')
@ApiOperation({ summary: 'Get contact details by ID (admin only)' })
@ApiParam({ name: 'id', description: 'Contact ID' })
async findOne(@RestId() restId: string, @Param('id') id: string) {
return this.contactService.findOne(id, restId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_CONTACTS)
@Patch('admin/contact/:id/status')
@ApiOperation({ summary: 'Update contact status (admin only)' })
@ApiParam({ name: 'id', description: 'Contact ID' })
async updateStatus(@RestId() restId: string, @Param('id') id: string, @Body() dto: UpdateContactStatusDto) {
return this.contactService.updateStatus(id, dto, restId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_CONTACTS)
@Delete('admin/contact/:id')
@ApiOperation({ summary: 'Hard delete a contact (admin only)' })
@ApiParam({ name: 'id', description: 'Contact ID' })
async remove(@RestId() restId: string, @Param('id') id: string) {
await this.contactService.remove(id, restId);
}
/*** Super Admin ***/
@UseGuards(SuperAdminAuthGuard)
@ApiBearerAuth()
@Get('super-admin/contact')
@ApiOperation({ summary: 'Get paginated list of contacts (admin only)' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
@ApiQuery({ name: 'status', required: false, enum: ['new', 'seen'] })
async findAllPaginatedForSuper(@Query() dto: FindContactsDto) {
return this.contactService.findAllPaginatedSuper(dto);
}
@UseGuards(SuperAdminAuthGuard)
@ApiBearerAuth()
@Get('super-admin/contact/:id')
@ApiOperation({ summary: 'Get contact details by ID (admin only)' })
@ApiParam({ name: 'id', description: 'Contact ID' })
async findOneForSuper(@Param('id') id: string) {
return this.contactService.findOne(id);
}
}
@@ -0,0 +1,26 @@
import { IsNotEmpty, IsString, IsOptional, IsMobilePhone, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ContactScope } from '../interface/interface';
export class CreateContactDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'Question about menu', description: 'Contact subject' })
subject?: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ example: 'I have a question about your menu items', description: 'Contact content/message' })
content!: string;
@IsNotEmpty()
@IsEnum(['application', 'restaurant'])
@ApiProperty({ description: 'Contact Scope' })
scope!: ContactScope;
@IsOptional()
@IsString()
@IsMobilePhone('fa-IR')
@ApiPropertyOptional({ example: '09362532122', description: 'Phone number' })
phone?: string;
}
@@ -0,0 +1,42 @@
import { IsOptional, IsNumber, IsString, IsIn, IsEnum } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ContactStatusEnum } from '../interface/interface';
const sortOrderOptions = ['asc', 'desc'] as const;
type SortOrder = (typeof sortOrderOptions)[number];
export class FindContactsDto {
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiPropertyOptional({ example: 1, description: 'Page number', minimum: 1, default: 1 })
page?: number = 1;
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiPropertyOptional({ example: 10, description: 'Items per page', minimum: 1, default: 10 })
limit?: number = 10;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'menu', description: 'Search in subject, content, or phone' })
search?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'createdAt', description: 'Field to sort by', default: 'createdAt' })
orderBy?: string = 'createdAt';
@IsOptional()
@IsIn(sortOrderOptions)
@ApiPropertyOptional({ example: 'desc', enum: sortOrderOptions, default: 'desc' })
order?: SortOrder = 'desc';
@IsOptional()
@IsEnum(ContactStatusEnum)
@ApiPropertyOptional({ enum: ContactStatusEnum, description: 'Filter by status' })
status?: ContactStatusEnum;
}
@@ -0,0 +1,10 @@
import { IsEnum, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { ContactStatusEnum } from '../interface/interface';
export class UpdateContactStatusDto {
@IsNotEmpty()
@IsEnum(ContactStatusEnum)
@ApiProperty({ enum: ContactStatusEnum, description: 'New status for the contact' })
status!: ContactStatusEnum;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateContactDto } from './create-contact.dto';
export class UpdateContactDto extends PartialType(CreateContactDto) {}
@@ -0,0 +1,26 @@
import { Entity, Enum, ManyToOne, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { ContactScope, ContactStatusEnum } from '../interface/interface';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
@Entity({ tableName: 'contacts' })
export class Contact extends BaseEntity {
@Property({ nullable: true })
subject?: string | null = null;
@Property()
content!: string;
@Property({ nullable: true })
phone?: string | null = null;
@Enum(() => ContactScope)
scope: ContactScope
@ManyToOne(() => Restaurant, { nullable: true })
restaurant?: Restaurant | null = null;
@Property({ default: ContactStatusEnum.New })
status: ContactStatusEnum = ContactStatusEnum.New;
}
@@ -0,0 +1,9 @@
export enum ContactStatusEnum {
New = 'new',
Seen = 'seen',
}
export enum ContactScope {
APPLICATION = 'application',
RESTAURANT = 'restaurant',
}
@@ -0,0 +1,108 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateContactDto } from '../dto/create-contact.dto';
import { FindContactsDto } from '../dto/find-contacts.dto';
import { UpdateContactStatusDto } from '../dto/update-contact-status.dto';
import { ContactRepository } from '../repositories/contact.repository';
import { Contact } from '../entities/contact.entity';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { ContactStatusEnum } from '../interface/interface';
import { EntityManager } from '@mikro-orm/postgresql';
import { ContactScope } from '../interface/interface';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
@Injectable()
export class ContactService {
constructor(
private readonly contactRepository: ContactRepository,
private readonly em: EntityManager,
) { }
async create(dto: CreateContactDto, restId?: string, slug?: string): Promise<Contact> {
let restaurant: Restaurant | null = null;
if ((restId || slug) && dto.scope === ContactScope.RESTAURANT) {
restaurant = await this.em.findOne(Restaurant, {
...(restId ? { id: restId } : {})
, ...(slug ? { slug: slug } : {})
});
if (!restaurant) {
throw new NotFoundException(`Restaurant with ID ${restId} not found`);
}
}
const contact = this.contactRepository.create({
...dto,
status: ContactStatusEnum.New,
restaurant,
});
await this.em.persistAndFlush(contact);
return contact;
}
async findAllPaginatedAdmin(dto: FindContactsDto, restaurantId?: string): Promise<PaginatedResult<Contact>> {
return this.contactRepository.findAllPaginated({
page: dto.page,
limit: dto.limit,
search: dto.search,
orderBy: dto.orderBy,
order: dto.order,
status: dto.status,
scope: ContactScope.RESTAURANT,
restaurantId
});
}
async findAllPaginatedSuper(dto: FindContactsDto): Promise<PaginatedResult<Contact>> {
return this.contactRepository.findAllPaginated({
page: dto.page,
limit: dto.limit,
search: dto.search,
orderBy: dto.orderBy,
order: dto.order,
status: dto.status,
scope: ContactScope.APPLICATION
});
}
async findOne(id: string, restaurantId?: string): Promise<Contact> {
const where: any = { id };
if (restaurantId) {
where.restaurant = restaurantId;
}
const contact = await this.contactRepository.findOne(where);
if (!contact) {
throw new NotFoundException(`Contact with ID ${id} not found`);
}
return contact;
}
async updateStatus(id: string, dto: UpdateContactStatusDto, restaurantId?: string): Promise<Contact> {
const where: any = { id };
if (restaurantId) {
where.restaurant = restaurantId;
}
const contact = await this.contactRepository.findOne(where);
if (!contact) {
throw new NotFoundException(`Contact with ID ${id} not found`);
}
contact.status = dto.status;
await this.em.persistAndFlush(contact);
return contact;
}
async remove(id: string, restaurantId?: string): Promise<void> {
const where: any = { id };
if (restaurantId) {
where.restaurant = restaurantId;
}
const contact = await this.contactRepository.findOne(where);
if (!contact) {
throw new NotFoundException(`Contact with ID ${id} not found`);
}
await this.em.removeAndFlush(contact);
}
}
@@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { FilterQuery } from '@mikro-orm/core';
import { Contact } from '../entities/contact.entity';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { ContactScope, ContactStatusEnum } from '../interface/interface';
type FindContactsOpts = {
page?: number;
limit?: number;
search?: string;
orderBy?: string;
order?: 'asc' | 'desc';
status?: ContactStatusEnum;
scope?: ContactScope;
restaurantId?: string;
};
@Injectable()
export class ContactRepository extends EntityRepository<Contact> {
constructor(readonly em: EntityManager) {
super(em, Contact);
}
/**
* Find contacts with pagination and optional filters.
* Supports: search (subject/content/phone), status, ordering.
*/
async findAllPaginated(opts: FindContactsOpts = {}): Promise<PaginatedResult<Contact>> {
const { page = 1, limit = 10, search, scope, orderBy = 'createdAt', order = 'desc', status, restaurantId } = opts;
const offset = (page - 1) * limit;
const where: FilterQuery<Contact> = {};
if (status) {
where.status = status;
}
if (scope) {
where.scope = scope;
}
if (restaurantId) {
where.restaurant = restaurantId;
}
if (search) {
const pattern = `%${search}%`;
where.$or = [{ subject: { $ilike: pattern } }, { content: { $ilike: pattern } }, { phone: { $ilike: pattern } }];
}
const [data, total] = await this.findAndCount(where, {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
});
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
},
};
}
}
@@ -0,0 +1,112 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards } from '@nestjs/common';
import { CouponService } from '../providers/coupon.service';
import { CreateCouponDto } from '../dto/create-coupon.dto';
import { UpdateCouponDto } from '../dto/update-coupon.dto';
import { FindCouponsDto } from '../dto/find-coupons.dto';
import {
ApiTags,
ApiOperation,
ApiCreatedResponse,
ApiOkResponse,
ApiNotFoundResponse,
ApiQuery,
ApiBody,
ApiParam,
ApiBearerAuth,
ApiHeader,
} from '@nestjs/swagger';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { RestId } from 'src/common/decorators';
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
import { API_HEADER_SLUG } from 'src/common/constants';
import { Permission } from 'src/common/enums/permission.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
@ApiTags('coupons')
@Controller()
export class CouponController {
constructor(private readonly couponService: CouponService) { }
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Get('public/coupons/me')
@ApiOperation({ summary: 'Get paginated list of restaurant coupons' })
@ApiHeader(API_HEADER_SLUG)
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
@ApiQuery({ name: 'type', required: false, enum: ['PERCENTAGE', 'FIXED'] })
@ApiQuery({ name: 'isActive', required: false, type: Boolean })
getMyCoupons(@Query() dto: FindCouponsDto, @RestId() restId: string) {
return this.couponService.findAll(restId, dto);
}
/*** Admin ***/
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_COUPONS)
@Post('admin/coupons')
@ApiOperation({ summary: 'Create a new coupon' })
@ApiCreatedResponse({ description: 'The coupon has been successfully created.', type: CreateCouponDto })
@ApiBody({ type: CreateCouponDto })
create(@Body() createCouponDto: CreateCouponDto, @RestId() restId: string) {
return this.couponService.create(restId, createCouponDto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_COUPONS)
@Get('admin/coupons')
@ApiOperation({ summary: 'Get a paginated list of coupons' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
@ApiQuery({ name: 'type', required: false, enum: ['PERCENTAGE', 'FIXED'] })
@ApiQuery({ name: 'isActive', required: false, type: Boolean })
findAll(@Query() dto: FindCouponsDto, @RestId() restId: string) {
return this.couponService.findAll(restId, dto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_COUPONS)
@Get('admin/coupons/:id')
@ApiOperation({ summary: 'Get a coupon by id' })
@ApiParam({ name: 'id', required: true })
findById(@Param('id') id: string) {
return this.couponService.findById(id);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_COUPONS)
@Patch('admin/coupons/:id')
@ApiOperation({ summary: 'Update a coupon' })
@ApiParam({ name: 'id', required: true })
@ApiBody({ type: UpdateCouponDto })
update(@Param('id') id: string, @Body() updateCouponDto: UpdateCouponDto) {
return this.couponService.update(id, updateCouponDto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_COUPONS)
@Delete('admin/coupons/:id')
@ApiOperation({ summary: 'Delete (soft) a coupon' })
@ApiParam({ name: 'id', required: true })
remove(@Param('id') id: string) {
return this.couponService.remove(id);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_COUPONS)
@Get('admin/coupons/generate-code')
@ApiOperation({ summary: 'generate a coupon code' })
generateCouponCode(@RestId() restId: string) {
return this.couponService.generateCouponCode(restId);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { CouponService } from './providers/coupon.service';
import { CouponController } from './controllers/coupon.controller';
import { CouponRepository } from './repositories/coupon.repository';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Coupon } from './entities/coupon.entity';
import { RestaurantsModule } from '../restaurants/restaurants.module';
import { AuthModule } from '../auth/auth.module';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [MikroOrmModule.forFeature([Coupon]), RestaurantsModule, AuthModule, JwtModule],
controllers: [CouponController],
providers: [CouponService, CouponRepository],
exports: [CouponRepository, CouponService],
})
export class CouponModule {}
@@ -0,0 +1,115 @@
import {
IsNotEmpty,
IsString,
IsEnum,
IsNumber,
IsOptional,
IsBoolean,
IsDateString,
Min,
IsArray,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { CouponType } from '../interface/coupon';
export class CreateCouponDto {
@IsNotEmpty()
@IsString()
@ApiProperty({ example: 'DISCOUNT10', description: 'Unique coupon code' })
code!: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ example: '10% Off', description: 'Coupon name' })
name!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'Get 10% off on your order', description: 'Coupon description' })
description?: string;
@IsNotEmpty()
@IsEnum(CouponType)
@ApiProperty({ enum: CouponType, example: CouponType.PERCENTAGE, description: 'Coupon type' })
type!: CouponType;
@IsNotEmpty()
@IsNumber()
@Min(0)
@Type(() => Number)
@ApiProperty({ example: 10, description: 'Discount value (amount or percentage)' })
value!: number;
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
@ApiPropertyOptional({ example: 50000, description: 'Maximum discount amount (for percentage coupons)' })
maxDiscount?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
@ApiPropertyOptional({ example: 100000, description: 'Minimum order amount to use coupon' })
minOrderAmount?: number;
@IsOptional()
@IsNumber()
@Min(1)
@Type(() => Number)
@ApiPropertyOptional({ example: 100, description: 'Maximum number of times coupon can be used' })
maxUses?: number;
@IsOptional()
@IsNumber()
@Min(1)
@Type(() => Number)
@ApiPropertyOptional({ example: 1, description: 'Maximum uses per user' })
maxUsesPerUser?: number;
@IsOptional()
@IsDateString()
@ApiPropertyOptional({ example: '2024-01-01T00:00:00Z', description: 'Coupon validity start date' })
startDate?: string;
@IsOptional()
@IsDateString()
@ApiPropertyOptional({ example: '2024-12-31T23:59:59Z', description: 'Coupon validity end date' })
endDate?: string;
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
@ApiPropertyOptional({ example: true, description: 'Whether coupon is active' })
isActive?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ApiPropertyOptional({
example: ['category-id-1', 'category-id-2'],
description: 'Array of food category IDs. If empty, coupon applies to all categories.',
type: [String],
})
foodCategories?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
@ApiPropertyOptional({
example: ['food-id-1', 'food-id-2'],
description: 'Array of food IDs. If empty, coupon applies to all foods.',
type: [String],
})
foods?: string[];
@IsOptional()
@IsString()
@ApiPropertyOptional({
example: '09123456789',
description: 'Phone number of the user who can use this coupon. If empty, coupon can be used by any user.',
})
userPhone?: string;
}
@@ -0,0 +1,44 @@
import { IsOptional, IsString, IsBoolean, IsNumber, IsEnum } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { CouponType } from '../interface/coupon';
export class FindCouponsDto {
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiPropertyOptional({ example: 1, description: 'Page number' })
page?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiPropertyOptional({ example: 10, description: 'Items per page' })
limit?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'DISCOUNT', description: 'Search term for code or name' })
search?: string;
@IsOptional()
@IsEnum(CouponType)
@ApiPropertyOptional({ enum: CouponType, description: 'Filter by coupon type' })
type?: CouponType;
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
@ApiPropertyOptional({ example: true, description: 'Filter by active status' })
isActive?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'createdAt', description: 'Field to order by' })
orderBy?: string;
@IsOptional()
@IsEnum(['asc', 'desc'])
@ApiPropertyOptional({ enum: ['asc', 'desc'], example: 'desc', description: 'Sort order' })
order?: 'asc' | 'desc';
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCouponDto } from './create-coupon.dto';
export class UpdateCouponDto extends PartialType(CreateCouponDto) {}
@@ -0,0 +1,21 @@
import { IsNotEmpty, IsString, IsNumber, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class ValidateCouponDto {
@IsNotEmpty()
@IsString()
@ApiProperty({ example: 'DISCOUNT10', description: 'Coupon code to validate' })
code!: string;
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiPropertyOptional({ example: 150000, description: 'Order total amount for validation' })
orderAmount?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'user-id', description: 'User ID for per-user usage limits' })
userId?: string;
}
@@ -0,0 +1,71 @@
import { Entity, Index, ManyToOne, Property, Enum, Unique } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { normalizePhone } from '../../utils/phone.util';
import { CouponType } from '../interface/coupon';
@Entity({ tableName: 'coupons' })
@Unique({ properties: ['code', 'restaurant'] })
@Index({ properties: ['restaurant', 'code', 'isActive'] })
@Index({ properties: ['restaurant', 'isActive'] })
@Index({ properties: ['code'] })
export class Coupon extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property()
code!: string;
@Property()
name!: string;
@Property({ type: 'text', nullable: true })
description?: string;
@Enum(() => CouponType)
type!: CouponType;
@Property({ type: 'decimal', precision: 10, scale: 2 })
value!: number; // Discount amount or percentage
@Property({ type: 'decimal', precision: 10, scale: 2, nullable: true })
maxDiscount?: number; // Maximum discount for percentage coupons
@Property({ type: 'decimal', precision: 10, scale: 2, nullable: true })
minOrderAmount?: number; // Minimum order amount to use coupon
@Property({ type: 'int', nullable: true })
maxUses?: number; // Maximum number of times coupon can be used
@Property({ type: 'int', default: 0 })
usedCount: number = 0; // Number of times coupon has been used
@Property({ type: 'int', nullable: true })
maxUsesPerUser?: number; // Maximum uses per user
@Property({ type: 'timestamptz', nullable: true })
startDate?: Date; // Coupon validity start date
@Property({ type: 'timestamptz', nullable: true })
endDate?: Date; // Coupon validity end date
@Property({ type: 'boolean', default: true })
isActive: boolean = true;
@Property({ type: 'json', nullable: true })
foodCategories?: string[]; // Array of category IDs
@Property({ type: 'json', nullable: true })
foods?: string[]; // Array of food IDs
private _userPhone?: string;
@Property({ nullable: true })
get userPhone(): string | undefined {
return this._userPhone;
}
set userPhone(value: string | undefined) {
this._userPhone = value ? normalizePhone(value) : undefined;
}
}
+4
View File
@@ -0,0 +1,4 @@
export enum CouponType {
PERCENTAGE = 'PERCENTAGE',
FIXED = 'FIXED',
}
@@ -0,0 +1,271 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { CreateCouponDto } from '../dto/create-coupon.dto';
import { UpdateCouponDto } from '../dto/update-coupon.dto';
import { FindCouponsDto } from '../dto/find-coupons.dto';
import { CouponRepository } from '../repositories/coupon.repository';
import { RestRepository } from '../../restaurants/repositories/rest.repository';
import { EntityManager } from '@mikro-orm/postgresql';
import { RequiredEntityData } from '@mikro-orm/core';
import { Coupon } from '../entities/coupon.entity';
import { CouponType } from '../interface/coupon';
import { CouponMessage, RestMessage } from 'src/common/enums/message.enum';
import { randomBytes } from 'node:crypto';
@Injectable()
export class CouponService {
constructor(
private readonly couponRepository: CouponRepository,
private readonly restRepository: RestRepository,
private readonly em: EntityManager,
) { }
async create(restId: string, createCouponDto: CreateCouponDto): Promise<Coupon> {
const restaurant = await this.restRepository.findOne({ id: restId });
if (!restaurant) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
// Check if code already exists
const existingCoupon = await this.couponRepository.findOne({ code: createCouponDto.code });
if (existingCoupon) {
throw new BadRequestException(CouponMessage.CODE_ALREADY_EXISTS);
}
// Validate dates
if (createCouponDto.startDate && createCouponDto.endDate) {
const startDate = new Date(createCouponDto.startDate);
const endDate = new Date(createCouponDto.endDate);
if (endDate <= startDate) {
throw new BadRequestException(CouponMessage.END_DATE_MUST_BE_AFTER_START_DATE);
}
}
// Validate percentage coupon
if (createCouponDto.type === CouponType.PERCENTAGE) {
if (createCouponDto.value < 0 || createCouponDto.value > 100) {
throw new BadRequestException(CouponMessage.PERCENTAGE_MUST_BE_BETWEEN_0_AND_100);
}
}
const data: RequiredEntityData<Coupon> = {
restaurant,
code: createCouponDto.code,
name: createCouponDto.name,
description: createCouponDto.description,
type: createCouponDto.type,
value: createCouponDto.value,
maxDiscount: createCouponDto.maxDiscount,
minOrderAmount: createCouponDto.minOrderAmount,
maxUses: createCouponDto.maxUses,
maxUsesPerUser: createCouponDto.maxUsesPerUser,
startDate: createCouponDto.startDate ? new Date(createCouponDto.startDate) : undefined,
endDate: createCouponDto.endDate ? new Date(createCouponDto.endDate) : undefined,
isActive: createCouponDto.isActive ?? true,
usedCount: 0,
foodCategories: createCouponDto.foodCategories,
foods: createCouponDto.foods,
userPhone: createCouponDto.userPhone,
};
const coupon = this.couponRepository.create(data);
if (!coupon) {
throw new Error('Failed to create coupon entity');
}
await this.em.persistAndFlush(coupon);
return coupon;
}
findAll(restId: string, dto: FindCouponsDto) {
return this.couponRepository.findAllPaginated(restId, dto);
}
async findById(id: string): Promise<Coupon> {
const coupon = await this.couponRepository.findOne({ id }, { populate: ['restaurant'] });
if (!coupon) {
throw new NotFoundException(CouponMessage.NOT_FOUND);
}
return coupon;
}
async findByCode(code: string, restId: string): Promise<Coupon> {
const coupon = await this.couponRepository.findOne({ code, restaurant: { id: restId } });
if (!coupon) {
throw new NotFoundException(CouponMessage.NOT_FOUND);
}
return coupon;
}
async update(id: string, dto: UpdateCouponDto): Promise<Coupon> {
const coupon = await this.couponRepository.findOne({ id });
if (!coupon) {
throw new NotFoundException(CouponMessage.NOT_FOUND);
}
// Check if code is being changed and if it already exists
if (dto.code && dto.code !== coupon.code) {
const existingCoupon = await this.couponRepository.findOne({ code: dto.code });
if (existingCoupon) {
throw new BadRequestException(CouponMessage.CODE_ALREADY_EXISTS);
}
}
// Validate dates
const startDate = dto.startDate ? new Date(dto.startDate) : coupon.startDate;
const endDate = dto.endDate ? new Date(dto.endDate) : coupon.endDate;
if (startDate && endDate && endDate <= startDate) {
throw new BadRequestException(CouponMessage.END_DATE_MUST_BE_AFTER_START_DATE);
}
// Validate percentage coupon
if (dto.type === CouponType.PERCENTAGE || coupon.type === CouponType.PERCENTAGE) {
const value = dto.value ?? coupon.value;
if (value < 0 || value > 100) {
throw new BadRequestException(CouponMessage.PERCENTAGE_MUST_BE_BETWEEN_0_AND_100);
}
}
// Update fields
if (dto.code) coupon.code = dto.code;
if (dto.name) coupon.name = dto.name;
if (dto.description !== undefined) coupon.description = dto.description;
if (dto.type) coupon.type = dto.type;
if (dto.value !== undefined) coupon.value = dto.value;
if (dto.maxDiscount !== undefined) coupon.maxDiscount = dto.maxDiscount;
if (dto.minOrderAmount !== undefined) coupon.minOrderAmount = dto.minOrderAmount;
if (dto.maxUses !== undefined) coupon.maxUses = dto.maxUses;
if (dto.maxUsesPerUser !== undefined) coupon.maxUsesPerUser = dto.maxUsesPerUser;
if (dto.startDate !== undefined) coupon.startDate = dto.startDate ? new Date(dto.startDate) : undefined;
if (dto.endDate !== undefined) coupon.endDate = dto.endDate ? new Date(dto.endDate) : undefined;
if (dto.isActive !== undefined) coupon.isActive = dto.isActive;
if (dto.foodCategories !== undefined) coupon.foodCategories = dto.foodCategories;
if (dto.foods !== undefined) coupon.foods = dto.foods;
if (dto.userPhone !== undefined) coupon.userPhone = dto.userPhone;
await this.em.persistAndFlush(coupon);
return coupon;
}
async remove(id: string) {
const coupon = await this.couponRepository.findOne({ id });
if (!coupon) {
throw new NotFoundException(CouponMessage.NOT_FOUND);
}
coupon.deletedAt = new Date();
await this.em.persistAndFlush(coupon);
}
/**
* Validate coupon
*/
async validateCoupon(
code: string,
restId: string,
orderAmount: number,
userPhone: string,
): Promise<{
valid: boolean;
coupon?: Coupon;
message?: string;
}> {
const coupon = await this.couponRepository.findOne(
{ code, restaurant: { id: restId } },
{ populate: ['restaurant'] },
);
if (!coupon) {
return {
valid: false,
message: CouponMessage.NOT_FOUND,
};
}
if (coupon.userPhone) {
if (userPhone !== coupon.userPhone) {
return {
valid: false,
message: CouponMessage.COUPON_RESTRICTED_TO_USER,
};
}
}
// Check if coupon is active
if (!coupon.isActive) {
return {
valid: false,
message: CouponMessage.COUPON_INACTIVE,
};
}
// Check date validity
const now = new Date();
if (coupon.startDate && now < coupon.startDate) {
return {
valid: false,
message: CouponMessage.COUPON_NOT_STARTED,
};
}
if (coupon.endDate && now > coupon.endDate) {
return {
valid: false,
message: CouponMessage.COUPON_EXPIRED,
};
}
// Check max uses
if (coupon.maxUses && coupon.usedCount >= coupon.maxUses) {
return {
valid: false,
message: CouponMessage.COUPON_MAX_USES_REACHED,
};
}
// Check minimum order amount
if (coupon.minOrderAmount && orderAmount < coupon.minOrderAmount) {
return {
valid: false,
message: CouponMessage.MIN_ORDER_AMOUNT_NOT_MET,
};
}
return {
valid: true,
coupon,
};
}
/**
* Increment coupon usage count
*/
async incrementUsage(id: string): Promise<void> {
const coupon = await this.couponRepository.findOne({ id });
if (coupon) {
coupon.usedCount += 1;
await this.em.persistAndFlush(coupon);
}
}
generateShortCode(restaurantSlug: string, length = 5) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const bytes = randomBytes(length);
let result = '';
for (let i = 0; i < bytes.length; i++) {
result += chars[bytes[i] % chars.length];
}
const prefix = restaurantSlug.slice(0, 2).toUpperCase();
return prefix + '-' + result;
}
async generateCouponCode(restId: string): Promise<{ code: string }> {
const restaurant = await this.restRepository.findOne({ id: restId });
if (!restaurant) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
const code = this.generateShortCode(restaurant.slug);
const existing = await this.couponRepository.findOne({ code, restaurant: { id: restId } });
if (existing) return this.generateCouponCode(restId);
return { code };
}
}
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { FilterQuery } from '@mikro-orm/core';
import { Coupon } from '../entities/coupon.entity';
import { CouponType } from '../interface/coupon';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
type FindCouponsOpts = {
page?: number;
limit?: number;
search?: string;
orderBy?: string;
order?: 'asc' | 'desc';
type?: CouponType;
isActive?: boolean;
};
@Injectable()
export class CouponRepository extends EntityRepository<Coupon> {
constructor(readonly em: EntityManager) {
super(em, Coupon);
}
/**
* Find coupons with pagination and optional filters.
* Supports: search (code/name), type, isActive, ordering.
*/
async findAllPaginated(restId: string, opts: FindCouponsOpts = {}): Promise<PaginatedResult<Coupon>> {
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc', type, isActive } = opts;
const offset = (page - 1) * limit;
const where: FilterQuery<Coupon> = { restaurant: { id: restId } };
if (typeof isActive === 'boolean') {
where.isActive = isActive;
}
if (type) {
where.type = type;
}
if (search) {
const pattern = `%${search}%`;
where.$or = [{ code: { $ilike: pattern } }, { name: { $ilike: pattern } }];
}
const [data, total] = await this.findAndCount(where, {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
});
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
},
};
}
}
@@ -0,0 +1,88 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiCreatedResponse,
ApiOkResponse,
ApiNotFoundResponse,
ApiParam,
ApiBody,
ApiBearerAuth,
ApiHeader,
} from '@nestjs/swagger';
import { DeliveryService } from '../providers/delivery.service';
import { CreateDeliveryDto } from '../dto/create-delivery.dto';
import { UpdateDeliveryDto } from '../dto/update-delivery.dto';
import { RestId } from 'src/common/decorators/rest-id.decorator';
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { Delivery } from '../entities/delivery.entity';
import { API_HEADER_SLUG } from 'src/common/constants';
import { Permission } from 'src/common/enums/permission.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
@ApiTags('Delivery')
@Controller()
export class DeliveryController {
constructor(private readonly deliveryService: DeliveryService) {}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@Get('public/delivery-methods/restaurant')
@ApiOperation({ summary: 'Get restaurant delivery methods' })
@ApiHeader(API_HEADER_SLUG)
findAllDeliveryMethods(@RestId() restId: string) {
return this.deliveryService.findAllForRestaurantId(restId);
}
/*** Admin ***/
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_DELIVERY)
@Post('admin/delivery-methods/restaurant')
@ApiOperation({ summary: 'Create a delivery method for a restaurant' })
@ApiBody({ type: CreateDeliveryDto })
create(@Body() createDto: CreateDeliveryDto, @RestId() restId: string) {
return this.deliveryService.create(restId, createDto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_DELIVERY)
@Get('admin/delivery-methods/restaurant')
@ApiOperation({ summary: 'Get the restaurant delivery methods' })
findRestaurantDeliveryMethods(@RestId() restId: string) {
return this.deliveryService.findAllForRestaurantId(restId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_DELIVERY)
@Get('admin/delivery-methods/restaurant/:deliveryId')
@ApiOperation({ summary: 'Get a restaurant delivery method by delivery ID' })
@ApiParam({ name: 'deliveryId', description: 'Delivery method ID' })
findOne(@Param('deliveryId') deliveryId: string, @RestId() restId: string) {
return this.deliveryService.findOne(restId, deliveryId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_DELIVERY)
@Patch('admin/delivery-methods/restaurant/:deliveryId')
@ApiOperation({ summary: 'Update a restaurant delivery method' })
@ApiParam({ name: 'deliveryId', description: 'Delivery method ID' })
@ApiBody({ type: UpdateDeliveryDto })
update(@Param('deliveryId') deliveryId: string, @Body() updateDto: UpdateDeliveryDto, @RestId() restId: string) {
return this.deliveryService.update(restId, deliveryId, updateDto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_DELIVERY)
@Delete('admin/delivery-methods/restaurant/:deliveryId')
@ApiOperation({ summary: 'Delete a restaurant delivery method' })
@ApiParam({ name: 'deliveryId', description: 'Delivery method ID' })
remove(@Param('deliveryId') deliveryId: string, @RestId() restId: string) {
return this.deliveryService.remove(restId, deliveryId);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { JwtModule } from '@nestjs/jwt';
import { DeliveryController } from './controllers/delivery.controller';
import { DeliveryService } from './providers/delivery.service';
import { RestaurantsModule } from '../restaurants/restaurants.module';
import { Delivery } from './entities/delivery.entity';
import { DeliveryRepository } from './repositories/delivery.repository';
@Module({
controllers: [DeliveryController],
providers: [DeliveryService, DeliveryRepository],
exports: [DeliveryService, DeliveryRepository],
imports: [MikroOrmModule.forFeature([Delivery]), JwtModule, RestaurantsModule],
})
export class DeliveryModule {}
@@ -0,0 +1,60 @@
import { IsNumber, IsBoolean, IsOptional, IsString, Min, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { DeliveryFeeTypeEnum, DeliveryMethodEnum } from '../interface/delivery';
export class CreateDeliveryDto {
@ApiProperty({ example: 'dineIn', description: 'Delivery method name', enum: DeliveryMethodEnum })
@IsEnum(DeliveryMethodEnum)
method!: DeliveryMethodEnum;
@ApiProperty({ example: 5000, description: 'Delivery fee' })
@IsNumber()
@Min(0)
@Type(() => Number)
deliveryFee!: number;
@ApiProperty({ example: 100000, description: 'Minimum order price for free delivery' })
@IsNumber()
@Min(0)
@Type(() => Number)
minOrderPrice!: number;
@ApiPropertyOptional({ example: 'توضیحات روش ارسال', description: 'Delivery method description' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ example: true, description: 'Is this delivery method enabled?' })
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
enabled?: boolean;
@ApiPropertyOptional({ example: 0, description: 'Display order for sorting delivery methods' })
@IsOptional()
@IsNumber()
@Type(() => Number)
order?: number;
@ApiPropertyOptional({ example: 0, description: 'Kilometer number' })
@IsOptional()
@IsNumber()
@Type(() => Number)
distanceBasedMinCost?: number;
@ApiPropertyOptional({ example: 0, description: 'Delivery fee per kilometer' })
@IsOptional()
@IsNumber()
@Type(() => Number)
perKilometerFee?: number;
@ApiPropertyOptional({
example: DeliveryFeeTypeEnum.FIXED,
description: 'Delivery fee type',
enum: DeliveryFeeTypeEnum,
})
@IsOptional()
@IsEnum(DeliveryFeeTypeEnum)
deliveryFeeType!: DeliveryFeeTypeEnum;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateDeliveryDto } from './create-delivery.dto';
export class UpdateDeliveryDto extends PartialType(CreateDeliveryDto) {}
@@ -0,0 +1,37 @@
import { Entity, Property, Enum, ManyToOne } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { DeliveryFeeTypeEnum, DeliveryMethodEnum } from '../interface/delivery';
@Entity({ tableName: 'deliveries' })
export class Delivery extends BaseEntity {
@Enum(() => DeliveryMethodEnum)
method!: DeliveryMethodEnum;
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property({ type: 'number', default: 0 })
deliveryFee: number = 0;
@Enum(() => DeliveryFeeTypeEnum)
deliveryFeeType: DeliveryFeeTypeEnum = DeliveryFeeTypeEnum.FIXED;
@Property({ type: 'number', default: 0 })
perKilometerFee: number | null = null;
@Property({ type: 'number', default: 0 })
distanceBasedMinCost: number | null = null;
@Property({ type: 'number', default: 0 })
minOrderPrice: number = 0;
@Property({ nullable: true })
description?: string;
@Property({ default: true })
enabled: boolean = true;
@Property({ type: 'integer', default: 0 })
order: number = 0;
}
@@ -0,0 +1,11 @@
export enum DeliveryMethodEnum {
DineIn = 'dineIn',
CustomerPickup = 'customerPickup',
DeliveryCar = 'deliveryCar',
DeliveryCourier = 'deliveryCourier',
}
export enum DeliveryFeeTypeEnum {
FIXED = 'fixed',
DISTANCE_BASED = 'distanceBased',
}
@@ -0,0 +1,115 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { EntityManager, RequiredEntityData } from '@mikro-orm/postgresql';
import { Delivery } from '../entities/delivery.entity';
import { DeliveryRepository } from '../repositories/delivery.repository';
import { CreateDeliveryDto } from '../dto/create-delivery.dto';
import { UpdateDeliveryDto } from '../dto/update-delivery.dto';
import { RestRepository } from '../../restaurants/repositories/rest.repository';
import { DeliveryMethodEnum } from '../interface/delivery';
import { DeliveryMessage } from 'src/common/enums/message.enum';
@Injectable()
export class DeliveryService {
constructor(
private readonly deliveryRepository: DeliveryRepository,
private readonly restRepository: RestRepository,
private readonly em: EntityManager,
) {}
async create(restId: string, createDto: CreateDeliveryDto): Promise<Delivery> {
const restaurant = await this.restRepository.findOne({ id: restId });
if (!restaurant) {
throw new NotFoundException(DeliveryMessage.RESTAURANT_NOT_FOUND);
}
// Check if the delivery method with the same name already exists for this restaurant
const existing = await this.deliveryRepository.findOne({
restaurant: { id: restId },
method: createDto.method,
});
if (existing) {
throw new ConflictException('این روش ارسال قبلاً برای این رستوران ثبت شده است.');
}
const data: RequiredEntityData<Delivery> = {
restaurant,
method: createDto.method,
deliveryFee: createDto.deliveryFee,
minOrderPrice: createDto.minOrderPrice,
description: createDto.description,
enabled: createDto.enabled ?? true,
order: createDto.order ?? 0,
deliveryFeeType: createDto.deliveryFeeType,
perKilometerFee: createDto.perKilometerFee ?? null,
distanceBasedMinCost: createDto.distanceBasedMinCost ?? null,
};
const delivery = this.deliveryRepository.create(data);
await this.em.persistAndFlush(delivery);
return delivery;
}
async findAllForRestaurantId(restId: string): Promise<Delivery[]> {
return this.deliveryRepository.find({ restaurant: { id: restId } }, { orderBy: { order: 'asc' } });
}
async findOne(restId: string, deliveryId: string): Promise<Delivery> {
const delivery = await this.deliveryRepository.findOne({
id: deliveryId,
restaurant: { id: restId },
});
if (!delivery) {
throw new NotFoundException(DeliveryMessage.DELIVERY_METHOD_NOT_FOUND);
}
return delivery;
}
async update(restId: string, deliveryId: string, updateDto: UpdateDeliveryDto): Promise<Delivery> {
const delivery = await this.deliveryRepository.findOne({
restaurant: { id: restId },
id: deliveryId,
});
if (!delivery) {
throw new NotFoundException(DeliveryMessage.DELIVERY_METHOD_NOT_FOUND);
}
// If method is being updated, check for conflicts
if (updateDto.method !== undefined && updateDto.method !== delivery.method) {
const existing = await this.deliveryRepository.findOne({
restaurant: { id: restId },
method: updateDto.method,
id: { $ne: deliveryId },
});
if (existing) {
throw new ConflictException('این روش ارسال قبلاً برای این رستوران ثبت شده است.');
}
}
this.em.assign(delivery, updateDto);
await this.em.persistAndFlush(delivery);
return delivery;
}
async remove(restId: string, deliveryId: string): Promise<void> {
const delivery = await this.deliveryRepository.findOne({
restaurant: { id: restId },
id: deliveryId,
});
if (!delivery) {
throw new NotFoundException(DeliveryMessage.DELIVERY_METHOD_NOT_FOUND);
}
await this.em.removeAndFlush(delivery);
}
findAll(): DeliveryMethodEnum[] {
return Object.values(DeliveryMethodEnum);
}
}
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { Delivery } from '../entities/delivery.entity';
@Injectable()
export class DeliveryRepository extends EntityRepository<Delivery> {
constructor(readonly em: EntityManager) {
super(em, Delivery);
}
}
@@ -0,0 +1,89 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { CategoryService } from '../providers/category.service';
import { CreateCategoryDto } from '../dto/create-category.dto';
import { UpdateCategoryDto } from '../dto/update-category.dto';
import {
ApiTags,
ApiOperation,
ApiCreatedResponse,
ApiOkResponse,
ApiNotFoundResponse,
ApiParam,
ApiBody,
ApiBearerAuth,
ApiHeader,
} from '@nestjs/swagger';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { UseGuards } from '@nestjs/common';
import { RestId } from 'src/common/decorators';
import { Permission } from 'src/common/enums/permission.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { API_HEADER_SLUG } from 'src/common/constants';
@ApiTags('category')
@Controller()
export class CategoryController {
constructor(private readonly categoryService: CategoryService) { }
@Get('public/categories/restaurant/:slug')
@ApiOperation({ summary: 'Get all categories by restaurant slug' })
@ApiHeader(API_HEADER_SLUG)
@ApiParam({ name: 'slug', required: true, description: 'Restaurant Slug' })
@ApiOkResponse({ description: 'List of all categories for the restaurant' })
findAllByRestaurant(@Param('slug') slug: string) {
return this.categoryService.findAllByRestaurant(slug);
}
/*** Admin ***/
@ApiOperation({ summary: 'Create category' })
@ApiBody({ type: CreateCategoryDto })
@Permissions(Permission.MANAGE_CATEGORIES)
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Post('admin/categories')
create(@Body() dto: CreateCategoryDto, @RestId() restId: string) {
return this.categoryService.create(restId, dto);
}
@Permissions(Permission.MANAGE_CATEGORIES)
@ApiOperation({ summary: 'my restaurant categories' })
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/categories')
findAll(@RestId() restId: string) {
return this.categoryService.findAllByRestaurantId(restId);
}
@Permissions(Permission.MANAGE_CATEGORIES)
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Get('admin/categories/:id')
@ApiOperation({ summary: 'Get category' })
@ApiParam({ name: 'id', required: true, type: String })
findOne(@Param('id') id: string, @RestId() restId: string) {
return this.categoryService.findOne(restId, id);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_CATEGORIES)
@Patch('admin/categories/:id')
@ApiOperation({ summary: 'Update category' })
@ApiParam({ name: 'id' })
@ApiBody({ type: UpdateCategoryDto })
update(@Param('id') id: string, @Body() dto: UpdateCategoryDto, @RestId() restId: string) {
return this.categoryService.update(restId, id, dto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_CATEGORIES)
@Delete('admin/categories/:id')
@ApiOperation({ summary: 'Delete category' })
@ApiParam({ name: 'id' })
@ApiOkResponse({ description: 'Deleted' })
remove(@Param('id') id: string, @RestId() restId: string) {
return this.categoryService.remove(restId, id);
}
}
@@ -0,0 +1,123 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards } from '@nestjs/common';
import { FoodService } from '../providers/food.service';
import { CreateFoodDto } from '../dto/create-food.dto';
import { UpdateFoodDto } from '../dto/update-food.dto';
import { FindFoodsDto } from '../dto/find-foods.dto';
import {
ApiTags,
ApiOperation,
ApiQuery,
ApiBody,
ApiParam,
ApiBearerAuth,
ApiHeader,
} from '@nestjs/swagger';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { RestId, UserId } from 'src/common/decorators';
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
import { OptionalAuthGuard } from 'src/modules/auth/guards/optinalAuth.guard';
import { API_HEADER_SLUG } from 'src/common/constants';
import { Permission } from 'src/common/enums/permission.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
@ApiTags('foods')
@Controller()
export class FoodController {
constructor(private readonly foodsService: FoodService) { }
@Get('public/foods/restaurant/:slug')
@ApiOperation({ summary: 'Get all foods by restaurant slug' })
@ApiHeader(API_HEADER_SLUG)
@ApiParam({ name: 'slug', required: true, description: 'Restaurant Slug' })
findAllByRestaurant(@Param('slug') slug: string) {
return this.foodsService.findByWeekDateAndMealType(slug);
}
@Get('public/foods/:foodId')
@UseGuards(OptionalAuthGuard)
@ApiHeader(API_HEADER_SLUG)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get a food by id' })
@ApiParam({ name: 'foodId', required: true })
findPublicFoodById(@Param('foodId') foodId: string, @UserId() userId?: string): Promise<any> {
const a = this.foodsService.findPublicById(foodId, userId);
return a;
}
@Post('public/foods/favorite/:foodId')
@UseGuards(AuthGuard)
@ApiHeader(API_HEADER_SLUG)
@ApiBearerAuth()
@ApiOperation({ summary: 'toggle a food as favorite' })
@ApiParam({ name: 'foodId', required: true })
setAsFavorute(@Param('foodId') foodId: string, @UserId() userId: string) {
return this.foodsService.toggleFavorite(userId, foodId);
}
@Get('public/foods/favorite')
@UseGuards(AuthGuard)
@ApiHeader(API_HEADER_SLUG)
@ApiBearerAuth()
@ApiOperation({ summary: 'get my favorites' })
getMyFavorites(@UserId() userId: string, @RestId() restId: string) {
return this.foodsService.getMyFavorites(userId, restId);
}
/* ---------------------------------- Admin ---------------------------------- */
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_FOODS)
@Post('admin/foods')
@ApiOperation({ summary: 'Create a new food' })
@ApiBody({ type: CreateFoodDto })
create(@Body() createFoodDto: CreateFoodDto, @RestId() restId: string) {
return this.foodsService.create(restId, createFoodDto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_FOODS)
@Get('admin/foods')
@ApiOperation({ summary: 'Get a paginated list of foods' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
@ApiQuery({ name: 'categoryId', required: false, type: String })
@ApiQuery({ name: 'isActive', required: false, type: Boolean })
async findAll(@Query() dto: FindFoodsDto, @RestId() restId: string) {
const result = await this.foodsService.findAll(restId, dto);
return result;
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_FOODS)
@Get('admin/foods/:id')
@ApiOperation({ summary: 'Get a food by id' })
@ApiParam({ name: 'id', required: true })
findById(@Param('id') id: string, @RestId() restId: string) {
return this.foodsService.findAdminById(restId, id);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_FOODS)
@Patch('admin/foods/:id')
@ApiOperation({ summary: 'Update a food' })
@ApiParam({ name: 'id', required: true })
@ApiBody({ type: UpdateFoodDto })
update(@Param('id') id: string, @Body() updateFoodDto: UpdateFoodDto, @RestId() restId: string) {
return this.foodsService.update(restId, id, updateFoodDto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_FOODS)
@Delete('admin/foods/:id')
@ApiOperation({ summary: 'Delete (soft) a food' })
@ApiParam({ name: 'id', required: true })
remove(@Param('id') id: string, @RestId() restId: string) {
return this.foodsService.remove(restId, id);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { EntityManager } from '@mikro-orm/postgresql';
import { Inventory } from '../../inventory/entities/inventory.entity';
@Injectable()
export class FoodStockCrone {
private readonly logger = new Logger(FoodStockCrone.name);
constructor(private readonly em: EntityManager) {}
// run every day at 00:03
@Cron('3 0 * * *', {
name: 'resetAvailableStock',
timeZone: 'UTC',
})
async handleCron() {
try {
this.logger.debug('Starting daily inventory reset (availableStock = totalStock)');
const inventories = await this.em.find(Inventory, {});
if (!inventories || inventories.length === 0) {
this.logger.debug('No inventory records found to reset');
return;
}
this.logger.log(`Resetting available stock for ${inventories.length} inventory records`);
await this.em.transactional(async em => {
for (const inv of inventories) {
// reload inside transaction to avoid concurrency issues
const record = await em.findOne(Inventory, { id: inv.id });
if (!record) continue;
record.availableStock = record.totalStock;
em.persist(record);
}
await em.flush();
});
this.logger.log('Daily inventory reset completed');
} catch (err) {
this.logger.error(`FoodStockCrone failed: ${err?.message}`, err);
}
}
}
@@ -0,0 +1,27 @@
import { IsString, IsOptional, IsBoolean, IsInt, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class CreateCategoryDto {
@IsString()
@ApiPropertyOptional({ example: 'ایرانی' })
title!: string;
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
@ApiPropertyOptional({ example: true })
isActive?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'https://cdn.example.com/avatar.png' })
avatarUrl?: string;
@IsOptional()
@IsInt()
@Min(0)
@Type(() => Number)
@ApiPropertyOptional({ example: 1 })
order?: number;
}
+122
View File
@@ -0,0 +1,122 @@
import { Type } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { MealType } from '../interface/food.interface';
export class CreateFoodDto {
@IsNotEmpty()
@IsString()
@ApiProperty()
categoryId: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'قرمه سبزی' })
title?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'توضیحات غذا' })
desc?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ApiPropertyOptional({ type: [String] })
content?: string[];
@IsOptional()
@IsArray()
@ArrayUnique()
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
@Type(() => Number)
@ApiPropertyOptional({ type: [Number], example: [0, 1, 2, 3, 4, 5, 6] })
weekDays?: number[];
@IsOptional()
@IsArray()
@ArrayUnique()
@IsEnum(MealType, { each: true })
@ApiPropertyOptional({ enum: MealType, isArray: true })
mealTypes?: MealType[];
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
@ApiPropertyOptional({ example: 120000 })
price?: number;
@IsOptional()
@IsInt()
@Min(0)
@Type(() => Number)
@ApiPropertyOptional({ example: 15 })
prepareTime?: number;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ example: true })
@Type(() => Boolean)
isActive?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ApiPropertyOptional({ type: [String] })
images?: string[];
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ example: false })
@Type(() => Boolean)
inPlaceServe?: boolean;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ example: false })
@Type(() => Boolean)
pickupServe?: boolean;
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
@ApiPropertyOptional({ example: 0 })
discount?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
@ApiProperty({ example: 50 })
dailyStock: number;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ example: false })
@Type(() => Boolean)
isSpecialOffer?: boolean;
@IsOptional()
@IsInt()
@Min(0)
@Type(() => Number)
@ApiPropertyOptional({ example: 1 })
order?: number;
}
+43
View File
@@ -0,0 +1,43 @@
import { IsOptional, IsNumber, IsString, IsIn, IsBoolean } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class FindFoodsDto {
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiPropertyOptional({ example: 1 })
page?: number;
@IsOptional()
@IsNumber()
@Type(() => Number)
@ApiPropertyOptional({ example: 10 })
limit?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional()
search?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ example: 'createdAt' })
orderBy?: string;
@IsOptional()
@IsIn(['asc', 'desc'])
@ApiPropertyOptional({ example: 'desc' })
order?: 'asc' | 'desc';
@IsOptional()
@IsString()
@ApiPropertyOptional()
categoryId?: string;
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
@ApiPropertyOptional({ example: true })
isActive?: boolean;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCategoryDto } from './create-category.dto';
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
+4
View File
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateFoodDto } from './create-food.dto';
export class UpdateFoodDto extends PartialType(CreateFoodDto) {}
@@ -0,0 +1,27 @@
import { Entity, Index, Property, Collection, OneToMany, ManyToOne } from '@mikro-orm/core';
import { Food } from './food.entity';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
@Entity({ tableName: 'categories' })
@Index({ properties: ['restaurant', 'isActive'] })
@Index({ properties: ['isActive'] })
export class Category extends BaseEntity {
@Property()
title!: string;
@OneToMany(() => Food, food => food.category)
foods = new Collection<Food>(this);
@Property({ default: true })
isActive: boolean = true;
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@Property({ nullable: true })
avatarUrl?: string;
@Property({ type: 'int', nullable: true })
order?: number;
}
@@ -0,0 +1,14 @@
import { Entity, ManyToOne, Unique } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { User } from '../../users/entities/user.entity';
import { Food } from '../../foods/entities/food.entity';
@Entity({ tableName: 'favorites' })
@Unique({ properties: ['user', 'food'] })
export class Favorite extends BaseEntity {
@ManyToOne(() => User)
user: User;
@ManyToOne(() => Food)
food: Food;
}
+77
View File
@@ -0,0 +1,77 @@
import { Cascade, Collection, Entity, Index, ManyToOne, OneToMany, Property, OneToOne } from '@mikro-orm/core';
import { Category } from './category.entity';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../../modules/restaurants/entities/restaurant.entity';
import { Review } from 'src/modules/review/entities/review.entity';
import { Inventory } from 'src/modules/inventory/entities/inventory.entity';
import { MealType } from '../interface/food.interface';
import { Favorite } from './favorite.entity';
@Entity({ tableName: 'foods' })
@Index({ properties: ['restaurant', 'isActive'] })
@Index({ properties: ['category', 'isActive'] })
@Index({ properties: ['isActive'] })
export class Food extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant: Restaurant;
@ManyToOne(() => Category)
category: Category;
@OneToMany(() => Review, review => review.food, { cascade: [Cascade.ALL], orphanRemoval: true })
reviews = new Collection<Review>(this);
@OneToOne(() => Inventory, {
mappedBy: 'food',
nullable: true,
})
inventory?: Inventory;
@OneToMany(() => Favorite, favorite => favorite.food)
favorites = new Collection<Favorite>(this);
@Property({ nullable: true })
title?: string;
@Property({ type: 'text', nullable: true })
desc?: string;
@Property({ type: 'json', nullable: true })
content?: string[];
@Property({ type: 'decimal', precision: 10, scale: 2, nullable: true })
price?: number;
@Property({ type: 'int', nullable: true })
order?: number;
@Property({ type: 'int', nullable: true })
prepareTime?: number; // in minutes
@Property({ type: 'jsonb', default: [] })
weekDays: number[] = [0, 1, 2, 3, 4, 5, 6];
@Property({ type: 'jsonb', default: [] })
mealTypes: MealType[] = [];
@Property({ type: 'boolean', default: true })
isActive: boolean = true;
@Property({ type: 'json', nullable: true })
images?: string[];
@Property({ type: 'boolean', default: false })
inPlaceServe: boolean = false;
@Property({ type: 'boolean', default: false })
pickupServe: boolean = false;
@Property({ type: 'float', default: null })
score?: number | null = null;
@Property({ type: 'float', default: 0 })
discount: number = 0;
@Property({ type: 'boolean', default: false })
isSpecialOffer: boolean = false;
}
+30
View File
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { FoodService } from './providers/food.service';
import { FoodStockCrone } from './crone/food.crone';
import { FoodController } from './controllers/food.controller';
import { CategoryController } from './controllers/category.controller';
import { CategoryService } from './providers/category.service';
import { FoodRepository } from './repositories/food.repository';
import { CategoryRepository } from './repositories/category.repository';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Category } from './entities/category.entity';
import { Food } from './entities/food.entity';
import { RestaurantsModule } from '../restaurants/restaurants.module';
import { AuthModule } from '../auth/auth.module';
import { JwtModule } from '@nestjs/jwt';
import { UtilsModule } from '../utils/utils.module';
import { Favorite } from './entities/favorite.entity';
@Module({
imports: [
MikroOrmModule.forFeature([Food, Category, Favorite]),
RestaurantsModule,
AuthModule,
JwtModule,
UtilsModule,
],
controllers: [FoodController, CategoryController],
providers: [FoodService, CategoryService, FoodRepository, CategoryRepository, FoodStockCrone],
exports: [FoodRepository, CategoryRepository],
})
export class FoodModule {}
@@ -0,0 +1,6 @@
export enum MealType {
BREAKFAST = 'breakfast',
LUNCH = 'lunch',
DINNER = 'dinner',
SNACK = 'snack',
}
@@ -0,0 +1,81 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateCategoryDto } from '../dto/create-category.dto';
import { UpdateCategoryDto } from '../dto/update-category.dto';
import { CategoryRepository } from '../repositories/category.repository';
import { EntityManager } from '@mikro-orm/postgresql';
import { RequiredEntityData } from '@mikro-orm/core';
import { Category } from '../entities/category.entity';
import { CategoryMessage, RestMessage } from 'src/common/enums/message.enum';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
@Injectable()
export class CategoryService {
constructor(
private readonly categoryRepository: CategoryRepository,
private readonly em: EntityManager,
) { }
async create(restId: string, dto: CreateCategoryDto): Promise<Category> {
const restaurant = await this.em.findOne(Restaurant, { id: restId });
if (!restaurant) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
const data: RequiredEntityData<Category> = {
title: dto.title,
isActive: dto.isActive ?? true,
restaurant: restaurant,
avatarUrl: dto.avatarUrl,
};
const category = this.categoryRepository.create(data);
await this.em.persistAndFlush(category);
return category;
}
async findAllByRestaurant(slug: string): Promise<Category[]> {
const restaurant = await this.em.findOne(Restaurant, { slug });
if (!restaurant || !restaurant.id) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
return this.categoryRepository.find(
{ restaurant: restaurant, isActive: true }, { orderBy: { order: 'ASC' } });
}
async findAllByRestaurantId(restId: string): Promise<Category[]> {
return this.categoryRepository.find(
{ restaurant: { id: restId } },
{ orderBy: { order: 'asc' } });
}
async findOne(restId: string, id: string): Promise<Category> {
const cat = await this.categoryRepository.findOne({ id, restaurant: { id: restId } }, { populate: ['foods'] });
if (!cat) throw new NotFoundException(CategoryMessage.NOT_FOUND);
return {
id: cat.id,
title: cat.title,
isActive: cat.isActive,
restaurant: cat.restaurant,
avatarUrl: cat.avatarUrl,
createdAt: cat.createdAt,
updatedAt: cat.updatedAt,
foods: cat.foods.getItems().map(f => ({ id: f.id, title: f.title })),
} as unknown as Category;
}
async update(restId: string, id: string, dto: UpdateCategoryDto): Promise<Category> {
const cat = await this.categoryRepository.findOne({ id, restaurant: { id: restId } });
if (!cat) throw new NotFoundException(CategoryMessage.NOT_FOUND);
this.em.assign(cat, dto);
await this.em.persistAndFlush(cat);
return cat;
}
async remove(restId: string, id: string): Promise<void> {
const cat = await this.categoryRepository.findOne({ id, restaurant: { id: restId } });
if (!cat) throw new NotFoundException(CategoryMessage.NOT_FOUND);
cat.deletedAt = new Date();
await this.em.persistAndFlush(cat);
}
}
+273
View File
@@ -0,0 +1,273 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateFoodDto } from '../dto/create-food.dto';
import { FindFoodsDto } from '../dto/find-foods.dto';
import { FoodRepository } from '../repositories/food.repository';
import { CategoryRepository } from '../repositories/category.repository';
import { EntityManager } from '@mikro-orm/postgresql';
import { RequiredEntityData, FilterQuery } from '@mikro-orm/core';
import { Food } from '../entities/food.entity';
import { CategoryMessage, FoodMessage, RestMessage } from 'src/common/enums/message.enum';
import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
import { CacheService } from '../../utils/cache.service';
import { Favorite } from '../entities/favorite.entity';
import { MealType } from '../interface/food.interface';
import { Inventory } from 'src/modules/inventory/entities/inventory.entity';
@Injectable()
export class FoodService {
constructor(
private readonly foodRepository: FoodRepository,
private readonly categoryRepository: CategoryRepository,
private readonly restRepository: RestRepository,
private readonly em: EntityManager,
) { }
async create(restId: string, createFoodDto: CreateFoodDto) {
const { categoryId, dailyStock = 0, ...rest } = createFoodDto;
const restaurant = await this.restRepository.findOne({ id: restId });
if (!restaurant) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
const category = await this.categoryRepository.findOne({ id: categoryId, restaurant: { id: restId } });
if (!category) {
throw new NotFoundException(CategoryMessage.NOT_FOUND);
}
const { food, inventory } = await this.em.transactional(async em => {
// prepare data with defaults for required fields so repository.create typing is satisfied
const data: RequiredEntityData<Food> = {
desc: rest.desc,
isActive: rest.isActive ?? true,
inPlaceServe: rest.inPlaceServe ?? false,
pickupServe: rest.pickupServe ?? false,
discount: rest.discount ?? 0,
isSpecialOffer: rest.isSpecialOffer ?? false,
order: rest.order ?? null,
// map single-title/content DTO to localized fields
title: rest.title,
content: rest.content,
// numeric/array fields
price: rest.price ?? 0,
prepareTime: rest.prepareTime ?? 0,
images: rest.images ?? undefined,
// new fields
weekDays: rest.weekDays ?? [0, 1, 2, 3, 4, 5, 6],
mealTypes: rest.mealTypes ?? [],
restaurant: restaurant,
category: category,
};
const food = em.create(Food, data);
const newInventoryRecord = em.create(Inventory, {
food: food,
availableStock: dailyStock,
totalStock: dailyStock,
});
await em.flush();
return { food, inventory: newInventoryRecord };
});
// Re-load created entities with the primary EM to ensure they're attached
const savedFood = food?.id
? await this.foodRepository.findOne({ id: food.id }, { populate: ['category', 'restaurant'] })
: null;
const savedInventory = inventory?.id ? await this.em.findOne(Inventory, { id: inventory.id }) : inventory;
return { food: savedFood ?? food, inventory: savedInventory ?? inventory };
}
findAll(restId: string, dto: FindFoodsDto) {
return this.foodRepository.findAllPaginated(restId, dto);
}
/**
* Public food detail (only active foods are visible).
*/
async findPublicById(foodId: string, userId?: string): Promise<any> {
const food = await this.foodRepository.findOne({ id: foodId, isActive: true }, { populate: ['category', 'inventory'] });
if (!food) throw new NotFoundException(FoodMessage.NOT_FOUND);
let isFavorite = false;
if (userId) {
isFavorite = (await this.em.count(Favorite, { user: { id: userId }, food: { id: foodId } })) > 0;
}
return {
...food,
isFavorite,
};
}
/**
* Admin food detail (scoped to the authenticated restaurant).
*/
async findAdminById(restId: string, id: string): Promise<Food> {
const food = await this.foodRepository.findOne({ id, restaurant: { id: restId } }, { populate: ['category', 'inventory'] });
if (!food) throw new NotFoundException(FoodMessage.NOT_FOUND);
return food;
}
/**
* Find active foods for a restaurant based on current week day and meal type in Iran timezone.
* @param slug - Restaurant slug identifier
* @returns Array of active foods matching current day and meal time
*/
async findByWeekDateAndMealType(slug: string): Promise<Food[]> {
const restaurant = await this.restRepository.findOne({ slug });
if (!restaurant) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
const { iranWeekDay, mealType } = this.getCurrentIranTimeContext();
const queryFilter: FilterQuery<Food> = {
restaurant: { slug },
isActive: true,
weekDays: { $contains: iranWeekDay },
...(mealType ? { mealTypes: { $contains: mealType } } : {}),
} as unknown as FilterQuery<Food>;
return this.foodRepository.find(queryFilter, {
populate: ['category'],
orderBy: { order: 'asc' }
});
}
/**
* Get current Iran timezone context (weekday and meal type).
* @returns Object containing Iran weekday (0-6, where 0=Saturday) and current meal type
*/
private getCurrentIranTimeContext(): { iranWeekDay: number; mealType: MealType | null } {
const nowInIran = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Tehran' }));
const weekDay = nowInIran.getDay(); // 0 = Sunday, 6 = Saturday
const currentHour = nowInIran.getHours();
// Convert to Iran weekday: Saturday=0, Sunday=1, ..., Friday=6
// JavaScript: Sunday=0, Monday=1, ..., Saturday=6
// Iran week: Saturday=0, Sunday=1, ..., Friday=6
const iranWeekDay = (weekDay + 1) % 7;
const mealType = this.getMealTypeByHour(currentHour);
return { iranWeekDay, mealType };
}
/**
* Determine meal type based on current hour in Iran timezone.
* @param hour - Current hour (0-23)
* @returns Meal type or null if outside meal hours
*/
private getMealTypeByHour(hour: number): MealType | null {
const MEAL_TIME_RANGES = {
BREAKFAST: { start: 6, end: 11 },
LUNCH: { start: 11, end: 15 },
SNACK: { start: 15, end: 19 },
DINNER: { start: 19, end: 24 },
} as const;
if (hour >= MEAL_TIME_RANGES.BREAKFAST.start && hour < MEAL_TIME_RANGES.BREAKFAST.end) {
return MealType.BREAKFAST;
}
if (hour >= MEAL_TIME_RANGES.LUNCH.start && hour < MEAL_TIME_RANGES.LUNCH.end) {
return MealType.LUNCH;
}
if (hour >= MEAL_TIME_RANGES.SNACK.start && hour < MEAL_TIME_RANGES.SNACK.end) {
return MealType.SNACK;
}
if (hour >= MEAL_TIME_RANGES.DINNER.start && hour < MEAL_TIME_RANGES.DINNER.end) {
return MealType.DINNER;
}
return null;
}
async update(restId: string, id: string, dto: Partial<CreateFoodDto>): Promise<Food> {
const { categoryId, dailyStock, ...rest } = dto;
const food = await this.foodRepository.findOne({ id, restaurant: { id: restId } }, { populate: ['restaurant'] });
if (!food) {
throw new NotFoundException(FoodMessage.NOT_FOUND);
}
// attach new categories if provided (adds, does not attempt to preserve/remove existing ones)
if (categoryId) {
const category = await this.categoryRepository.findOne({ id: categoryId, restaurant: { id: restId } });
if (!category) {
throw new NotFoundException(CategoryMessage.NOT_FOUND);
}
this.em.assign(food, { category: category });
}
// assign other fields from DTO (excluding dailyStock handled below)
this.em.assign(food, rest);
// Persist food and update/create related inventory atomically
await this.em.transactional(async em => {
await em.persistAndFlush(food);
if (typeof dailyStock !== 'undefined') {
const inventoryRecord = await em.findOne(Inventory, { food: food });
if (inventoryRecord) {
inventoryRecord.totalStock = dailyStock;
} else {
em.create(Inventory, {
food: food,
availableStock: dailyStock,
totalStock: dailyStock,
});
}
}
await em.flush();
});
// Re-load the food to ensure populated relations and stable instance
const savedFood = await this.foodRepository.findOne({ id: food.id }, { populate: ['category', 'restaurant'] });
// Invalidate cache for the restaurant if present (optional)
// await this.invalidateRestaurantFoodsCache(savedFood?.restaurant?.slug);
return savedFood ?? food;
}
async remove(restId: string, id: string): Promise<void> {
const food = await this.foodRepository.findOne({ id, restaurant: { id: restId } }, { populate: ['restaurant'] });
if (!food) {
throw new NotFoundException(FoodMessage.NOT_FOUND);
}
// const restaurantSlug = food.restaurant.slug;
food.deletedAt = new Date();
await this.em.persistAndFlush(food);
// Invalidate cache for the restaurant
// await this.invalidateRestaurantFoodsCache(restaurantSlug);
}
async toggleFavorite(userId: string, foodId: string) {
const favorite = await this.em.findOne(Favorite, { user: userId, food: foodId });
if (favorite) {
return this.em.removeAndFlush(favorite);
}
const newFavorite = this.em.create(Favorite, {
user: userId,
food: foodId,
});
return this.em.persistAndFlush(newFavorite);
}
async getMyFavorites(userId: string, restId: string) {
return this.em.find(Favorite, { user: userId, food: { restaurant: { id: restId } } }, { populate: ['food'] });
}
/**
* Invalidate cache for restaurant foods
*/
// private async invalidateRestaurantFoodsCache(slug: string): Promise<void> {
// const cacheKey = `${this.FOODS_BY_RESTAURANT_CACHE_KEY_PREFIX}${slug}`;
// await this.cacheService.del(cacheKey);
// }
}
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { Category } from '../entities/category.entity';
@Injectable()
export class CategoryRepository extends EntityRepository<Category> {
constructor(readonly em: EntityManager) {
super(em, Category);
}
}
@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { FilterQuery } from '@mikro-orm/core';
import { Food } from '../entities/food.entity';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
type FindFoodsOpts = {
page?: number;
limit?: number;
search?: string;
orderBy?: string;
order?: 'asc' | 'desc';
categoryId?: string;
isActive?: boolean;
};
@Injectable()
export class FoodRepository extends EntityRepository<Food> {
constructor(readonly em: EntityManager) {
super(em, Food);
}
/**
* Find foods with pagination and optional filters.
* Supports: search (title/content), categoryId, isActive, ordering.
*/
async findAllPaginated(restId: string, opts: FindFoodsOpts = {}): Promise<PaginatedResult<Food>> {
const { page = 1, limit = 10, search, orderBy = 'order', order = 'asc', categoryId, isActive } = opts;
const offset = (page - 1) * limit;
const where: FilterQuery<Food> = { restaurant: { id: restId } };
if (typeof isActive === 'boolean') {
where.isActive = isActive;
}
if (search) {
const pattern = `%${search}%`;
where.$or = [{ title: { $ilike: pattern } }, { desc: { $ilike: pattern } }];
}
if (categoryId) {
// filter by related category (Food has a single `category` relation)
Object.assign(where, { category: { id: categoryId } } as unknown as FilterQuery<Food>);
}
const [data, total] = await this.findAndCount(where, {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
populate: ['category','inventory'],
});
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
},
};
}
}
@@ -0,0 +1,114 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { IconService } from '../providers/icon.service';
import { GroupService } from '../providers/group.service';
import { CreateIconDto } from '../dto/create-icon.dto';
import { UpdateIconDto } from '../dto/update-icon.dto';
import { CreateGroupDto } from '../dto/create-group.dto';
import { UpdateGroupDto } from '../dto/update-group.dto';
import { ApiTags, ApiOperation, ApiParam, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
import { UseGuards } from '@nestjs/common';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
@ApiTags('icons')
@Controller()
export class IconsController {
constructor(
private readonly iconService: IconService,
private readonly groupService: GroupService,
) {}
@Get('admin/groups/icons')
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all icons' })
findAllPublicIcons() {
return this.groupService.findAllGroups();
}
/**
* Super Admin Endpoints
*/
@Post('super-admin/icons')
@UseGuards(SuperAdminAuthGuard)
@ApiOperation({ summary: 'Create icon' })
@ApiBody({ type: CreateIconDto })
createIcon(@Body() dto: CreateIconDto) {
return this.iconService.create(dto);
}
@Get('super-admin/icons')
@UseGuards(SuperAdminAuthGuard)
@ApiOperation({ summary: 'Get all icons' })
findAllIcons() {
return this.iconService.findAll();
}
@Get('super-admin/icons/:id')
@UseGuards(SuperAdminAuthGuard)
@ApiOperation({ summary: 'Get icon by id' })
@ApiParam({ name: 'id', required: true, type: String })
findOneIcon(@Param('id') id: string) {
return this.iconService.findOne(id);
}
@Patch('super-admin/icons/:id')
@UseGuards(SuperAdminAuthGuard)
@ApiOperation({ summary: 'Update icon' })
@ApiParam({ name: 'id' })
@ApiBody({ type: UpdateIconDto })
updateIcon(@Param('id') id: string, @Body() dto: UpdateIconDto) {
return this.iconService.update(id, dto);
}
@Delete('super-admin/icons/:id')
@UseGuards(SuperAdminAuthGuard)
@ApiOperation({ summary: 'Delete icon' })
@ApiParam({ name: 'id' })
removeIcon(@Param('id') id: string) {
return this.iconService.remove(id);
}
// Group endpoints (must come before :id routes to avoid route conflicts)
@Post('super-admin/icons/groups')
@UseGuards(SuperAdminAuthGuard)
@ApiOperation({ summary: 'Create icon group' })
@ApiBody({ type: CreateGroupDto })
createGroup(@Body() dto: CreateGroupDto) {
return this.groupService.create(dto);
}
@Get('super-admin/icons/groups')
@UseGuards(SuperAdminAuthGuard)
@ApiOperation({ summary: 'Get all icon groups' })
async findAllGroups() {
console.log('find icona');
const a = await this.groupService.findAllGroups();
console.log(a);
return a;
}
@Get('super-admin/icons/groups/:id')
@UseGuards(SuperAdminAuthGuard)
@ApiOperation({ summary: 'Get icon group by id' })
@ApiParam({ name: 'id', required: true, type: String })
findOneGroup(@Param('id') id: string) {
return this.groupService.findOne(id);
}
@Patch('super-admin/icons/groups/:id')
@UseGuards(SuperAdminAuthGuard)
@ApiOperation({ summary: 'Update icon group' })
@ApiParam({ name: 'id' })
updateGroup(@Param('id') id: string, @Body() dto: UpdateGroupDto) {
return this.groupService.update(id, dto);
}
@Delete('super-admin/icons/groups/:id')
@UseGuards(SuperAdminAuthGuard)
@ApiOperation({ summary: 'Delete icon group' })
@ApiParam({ name: 'id' })
removeGroup(@Param('id') id: string) {
return this.groupService.remove(id);
}
}
@@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateGroupDto {
@IsString()
@ApiProperty({ example: 'Social Media' })
name!: string;
}
+12
View File
@@ -0,0 +1,12 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateIconDto {
@IsString()
@ApiProperty({ example: 'https://example.com/icons/facebook.svg' })
url!: string;
@IsString()
@ApiProperty({ example: 'group-id-here' })
groupId!: string;
}
@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { CreateGroupDto } from './create-group.dto';
export class UpdateGroupDto extends PartialType(CreateGroupDto) {}
+4
View File
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateIconDto } from './create-icon.dto';
export class UpdateIconDto extends PartialType(CreateIconDto) {}
@@ -0,0 +1,15 @@
import { Entity, Index, Property, Collection, OneToMany, Cascade } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Icon } from './icon.entity';
@Entity({ tableName: 'icon_groups' })
export class Group extends BaseEntity {
@Property()
name!: string;
@OneToMany(() => Icon, icon => icon.group,{
cascade: [Cascade.ALL],
orphanRemoval: true,
})
icons = new Collection<Icon>(this);
}
+13
View File
@@ -0,0 +1,13 @@
import { Entity, Index, Property, ManyToOne } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Group } from './group.entity';
@Entity({ tableName: 'icons' })
@Index({ properties: ['group'] })
export class Icon extends BaseEntity {
@Property()
url!: string;
@ManyToOne(() => Group)
group!: Group;
}
+18
View File
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Icon } from './entities/icon.entity';
import { Group } from './entities/group.entity';
import { IconService } from './providers/icon.service';
import { GroupService } from './providers/group.service';
import { IconRepository } from './repositories/icon.repository';
import { GroupRepository } from './repositories/group.repository';
import { IconsController } from './controllers/icons.controller';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [MikroOrmModule.forFeature([Icon, Group]), JwtModule],
controllers: [IconsController],
providers: [IconService, GroupService, IconRepository, GroupRepository],
exports: [IconRepository, GroupRepository, IconService, GroupService],
})
export class IconsModule {}
@@ -0,0 +1,56 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateGroupDto } from '../dto/create-group.dto';
import { UpdateGroupDto } from '../dto/update-group.dto';
import { GroupRepository } from '../repositories/group.repository';
import { EntityManager } from '@mikro-orm/postgresql';
import { RequiredEntityData } from '@mikro-orm/core';
import { Group } from '../entities/group.entity';
import { GroupMessage } from 'src/common/enums/message.enum';
@Injectable()
export class GroupService {
constructor(
private readonly groupRepository: GroupRepository,
private readonly em: EntityManager,
) {}
async create(dto: CreateGroupDto): Promise<Group> {
const data: RequiredEntityData<Group> = {
name: dto.name,
};
const group = this.groupRepository.create(data);
await this.em.persistAndFlush(group);
return group;
}
async findOne(id: string): Promise<Group> {
const group = await this.groupRepository.findOne({ id }, { populate: ['icons'] });
if (!group) {
throw new NotFoundException(GroupMessage.NOT_FOUND);
}
return group;
}
async update(id: string, dto: UpdateGroupDto): Promise<Group> {
const group = await this.groupRepository.findOne({ id });
if (!group) {
throw new NotFoundException(GroupMessage.NOT_FOUND);
}
this.em.assign(group, dto);
await this.em.persistAndFlush(group);
return group;
}
async remove(id: string): Promise<void> {
const group = await this.groupRepository.findOne({ id }, { populate: ['icons'] });
if (!group) {
throw new NotFoundException(GroupMessage.NOT_FOUND);
}
await this.em.removeAndFlush(group);
}
findAllGroups(): Promise<Group[]> {
return this.groupRepository.find({}, { populate: ['icons'] });
}
}
@@ -0,0 +1,76 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateIconDto } from '../dto/create-icon.dto';
import { UpdateIconDto } from '../dto/update-icon.dto';
import { IconRepository } from '../repositories/icon.repository';
import { EntityManager } from '@mikro-orm/postgresql';
import { RequiredEntityData } from '@mikro-orm/core';
import { Icon } from '../entities/icon.entity';
import { IconMessage, GroupMessage } from 'src/common/enums/message.enum';
import { Group } from '../entities/group.entity';
@Injectable()
export class IconService {
constructor(
private readonly iconRepository: IconRepository,
private readonly em: EntityManager,
) {}
async create(dto: CreateIconDto): Promise<Icon> {
const group = await this.em.findOne(Group, { id: dto.groupId });
if (!group) {
throw new NotFoundException(GroupMessage.NOT_FOUND);
}
const data: RequiredEntityData<Icon> = {
url: dto.url,
group: group,
};
const icon = this.iconRepository.create(data);
await this.em.persistAndFlush(icon);
return icon;
}
async findAll(): Promise<Icon[]> {
return this.iconRepository.find({}, { populate: ['group'] });
}
async findOne(id: string): Promise<Icon> {
const icon = await this.iconRepository.findOne({ id }, { populate: ['group'] });
if (!icon) {
throw new NotFoundException(IconMessage.NOT_FOUND);
}
return icon;
}
async update(id: string, dto: UpdateIconDto): Promise<Icon> {
const icon = await this.iconRepository.findOne({ id });
if (!icon) {
throw new NotFoundException(IconMessage.NOT_FOUND);
}
if (dto.groupId) {
const group = await this.em.findOne(Group, { id: dto.groupId });
if (!group) {
throw new NotFoundException(GroupMessage.NOT_FOUND);
}
icon.group = group;
}
this.em.assign(icon, {
url: dto.url,
});
await this.em.persistAndFlush(icon);
return icon;
}
async remove(id: string): Promise<void> {
const icon = await this.iconRepository.findOne({ id });
if (!icon) {
throw new NotFoundException(IconMessage.NOT_FOUND);
}
await this.em.removeAndFlush(icon);
}
}
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { Group } from '../entities/group.entity';
@Injectable()
export class GroupRepository extends EntityRepository<Group> {
constructor(readonly em: EntityManager) {
super(em, Group);
}
}
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { Icon } from '../entities/icon.entity';
@Injectable()
export class IconRepository extends EntityRepository<Icon> {
constructor(readonly em: EntityManager) {
super(em, Icon);
}
}
@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsString, IsNumber, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class BulkReserveFoodItemDto {
@ApiProperty({ example: 'food-123', description: 'Food ID' })
@IsNotEmpty()
@IsString()
foodId!: string;
@ApiProperty({ example: 5, description: 'Quantity to reserve' })
@IsNotEmpty()
@IsNumber()
@Min(1)
@Type(() => Number)
quantity!: number;
}
export class BulkReserveFoodDto {
@ApiProperty({
description: 'Array of food reservations to create',
type: [BulkReserveFoodItemDto],
example: [
{ foodId: 'food-123', quantity: 5 },
{ foodId: 'food-789', quantity: 3 },
],
})
@IsNotEmpty()
@IsArray()
@ArrayMinSize(1, { message: 'At least one item is required' })
@ValidateNested({ each: true })
@Type(() => BulkReserveFoodItemDto)
items!: BulkReserveFoodItemDto[];
}

Some files were not shown because too many files have changed in this diff Show More