user module
This commit is contained in:
@@ -5,7 +5,7 @@ import { UpdateAdminDto } from '../dto/update-admin.dto';
|
|||||||
import { ApiTags, ApiOperation, ApiBody, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBody, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
|
||||||
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||||
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
|
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
|
||||||
import { RestId } from 'src/common/decorators';
|
import { } from 'src/common/decorators';
|
||||||
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
||||||
import { Admin } from '../entities/admin.entity';
|
import { Admin } from '../entities/admin.entity';
|
||||||
import { Permission } from 'src/common/enums/permission.enum';
|
import { Permission } from 'src/common/enums/permission.enum';
|
||||||
@@ -23,7 +23,7 @@ export class AdminController {
|
|||||||
@Permissions(Permission.MANAGE_ADMINS)
|
@Permissions(Permission.MANAGE_ADMINS)
|
||||||
@ApiOperation({ summary: 'admin' })
|
@ApiOperation({ summary: 'admin' })
|
||||||
async getAll() {
|
async getAll() {
|
||||||
const admin = await this.adminService.findAllByRestaurantId(restId);
|
const admin = await this.adminService.findAllBy();
|
||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export class AdminController {
|
|||||||
@Permissions(Permission.MANAGE_ADMINS)
|
@Permissions(Permission.MANAGE_ADMINS)
|
||||||
@ApiOperation({ summary: 'admin' })
|
@ApiOperation({ summary: 'admin' })
|
||||||
async getMe(@AdminId() adminId: string,) {
|
async getMe(@AdminId() adminId: string,) {
|
||||||
const admin = await this.adminService.findById(adminId, restId);
|
const admin = await this.adminService.findById(adminId,);
|
||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export class AdminController {
|
|||||||
@ApiOperation({ summary: 'Create a new admin' })
|
@ApiOperation({ summary: 'Create a new admin' })
|
||||||
@ApiBody({ type: CreateAdminDto })
|
@ApiBody({ type: CreateAdminDto })
|
||||||
async create(@Body() dto: CreateAdminDto,) {
|
async create(@Body() dto: CreateAdminDto,) {
|
||||||
const admin = await this.adminService.createAdminForMyRestaurant(restId, dto);
|
const admin = await this.adminService.createAdminForMyRestaurant(, dto);
|
||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export class AdminController {
|
|||||||
@ApiParam({ name: 'adminId', description: 'Admin ID' })
|
@ApiParam({ name: 'adminId', description: 'Admin ID' })
|
||||||
@ApiBody({ type: UpdateAdminDto })
|
@ApiBody({ type: UpdateAdminDto })
|
||||||
update(@Param('adminId') adminId: string, @Body() dto: UpdateAdminDto,): Promise<Admin> {
|
update(@Param('adminId') adminId: string, @Body() dto: UpdateAdminDto,): Promise<Admin> {
|
||||||
return this.adminService.update(adminId, restId, dto);
|
return this.adminService.update(adminId, , dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('admin/admins/:adminId')
|
@Get('admin/admins/:adminId')
|
||||||
@@ -63,7 +63,7 @@ export class AdminController {
|
|||||||
@ApiOperation({ summary: 'Get an admin by ID' })
|
@ApiOperation({ summary: 'Get an admin by ID' })
|
||||||
@ApiParam({ name: 'id', description: 'Admin ID' })
|
@ApiParam({ name: 'id', description: 'Admin ID' })
|
||||||
getById(@Param('adminId') adminId: string,): Promise<Admin | null> {
|
getById(@Param('adminId') adminId: string,): Promise<Admin | null> {
|
||||||
return this.adminService.findById(adminId, restId);
|
return this.adminService.findById(adminId,);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('admin/admins/:adminId')
|
@Delete('admin/admins/:adminId')
|
||||||
@@ -72,39 +72,39 @@ export class AdminController {
|
|||||||
@ApiOperation({ summary: 'Delete an admin by ID' })
|
@ApiOperation({ summary: 'Delete an admin by ID' })
|
||||||
@ApiParam({ name: 'id', description: 'Admin ID' })
|
@ApiParam({ name: 'id', description: 'Admin ID' })
|
||||||
deleteById(@Param('adminId') adminId: string,): Promise<void> {
|
deleteById(@Param('adminId') adminId: string,): Promise<void> {
|
||||||
return this.adminService.remove(adminId, restId);
|
return this.adminService.remove(adminId,);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Super Admin Endpoints */
|
/** Super Admin Endpoints */
|
||||||
@UseGuards(SuperAdminAuthGuard)
|
@UseGuards(SuperAdminAuthGuard)
|
||||||
@Get('super-admin/restaurants/:restaurantId/admins')
|
@Get('super-admin/restaurants/:/admins')
|
||||||
@ApiOperation({ summary: 'Get admins for a specific restaurant (Super Admin only)' })
|
@ApiOperation({ summary: 'Get admins for a specific restaurant (Super Admin only)' })
|
||||||
@ApiParam({ name: 'restaurantId', description: 'Restaurant ID' })
|
@ApiParam({ name: '', description: 'Restaurant ID' })
|
||||||
getAdminForRestaurant(@Param('restaurantId') restaurantId: string): Promise<Admin[]> {
|
getAdminForRestaurant(@Param('') : string): Promise<Admin[]> {
|
||||||
return this.adminService.getAdminsForRestaurantBySuperAdmin(restaurantId);
|
return this.adminService.getAdminsForRestaurantBySuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(SuperAdminAuthGuard)
|
@UseGuards(SuperAdminAuthGuard)
|
||||||
@Post('super-admin/restaurants/:restaurantId/admins')
|
@Post('super-admin/restaurants/:/admins')
|
||||||
@ApiOperation({ summary: 'Create admin for a specific restaurant (Super Admin only)' })
|
@ApiOperation({ summary: 'Create admin for a specific restaurant (Super Admin only)' })
|
||||||
@ApiParam({ name: 'restaurantId', description: 'Restaurant ID' })
|
@ApiParam({ name: '', description: 'Restaurant ID' })
|
||||||
@ApiBody({ type: CreateMyRestaurantAdminDto })
|
@ApiBody({ type: CreateMyRestaurantAdminDto })
|
||||||
createAdminForRestaurant(
|
createAdminForRestaurant(
|
||||||
@Param('restaurantId') restaurantId: string,
|
@Param('') : string,
|
||||||
@Body() dto: CreateMyRestaurantAdminDto,
|
@Body() dto: CreateMyRestaurantAdminDto,
|
||||||
): Promise<Admin> {
|
): Promise<Admin> {
|
||||||
return this.adminService.createAdminForRestaurantBySuperAdmin(restaurantId, dto);
|
return this.adminService.createAdminForRestaurantBySuperAdmin(, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(SuperAdminAuthGuard)
|
@UseGuards(SuperAdminAuthGuard)
|
||||||
@Delete('super-admin/restaurants/:restaurantId/admins/:adminId')
|
@Delete('super-admin/restaurants/:/admins/:adminId')
|
||||||
@ApiOperation({ summary: 'Revoke admin role from a restaurant (Super Admin only)' })
|
@ApiOperation({ summary: 'Revoke admin role from a restaurant (Super Admin only)' })
|
||||||
@ApiParam({ name: 'restaurantId', description: 'Restaurant ID' })
|
@ApiParam({ name: '', description: 'Restaurant ID' })
|
||||||
@ApiParam({ name: 'adminId', description: 'Admin ID' })
|
@ApiParam({ name: 'adminId', description: 'Admin ID' })
|
||||||
revokeAdminFromRestaurant(
|
revokeAdminFromRestaurant(
|
||||||
@Param('restaurantId') restaurantId: string,
|
@Param('') : string,
|
||||||
@Param('adminId') adminId: string,
|
@Param('adminId') adminId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.adminService.remove(adminId, restaurantId);
|
return this.adminService.remove(adminId,);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,19 +27,19 @@ export class AdminService {
|
|||||||
return this.adminRepository.findOne({ phone: normalizedPhone });
|
return this.adminRepository.findOne({ phone: normalizedPhone });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(adminId: string, restId: string): Promise<Admin | null> {
|
async findById(adminId: string, : string): Promise<Admin | null> {
|
||||||
const admin = await this.adminRepository.findOne(
|
const admin = await this.adminRepository.findOne(
|
||||||
{ id: adminId, roles: { restaurant: { id: restId } } },
|
{ id: adminId, roles: { restaurant: { id: } } },
|
||||||
{ populate: ['roles', 'roles.role'] },
|
{ populate: ['roles', 'roles.role'] },
|
||||||
);
|
);
|
||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllByRestaurantId(restId: string): Promise<Admin[]> {
|
async findAllBy(: string): Promise<Admin[]> {
|
||||||
return this.adminRepository.find({ roles: { restaurant: { id: restId } } }, { populate: ['roles', 'roles.role'] });
|
return this.adminRepository.find({ roles: { restaurant: { id: } } }, { populate: ['roles', 'roles.role'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAdminForMyRestaurant(restId: string, dto: CreateMyRestaurantAdminDto) {
|
async createAdminForMyRestaurant(: string, dto: CreateMyRestaurantAdminDto) {
|
||||||
const { phone, firstName, lastName, roleId } = dto;
|
const { phone, firstName, lastName, roleId } = dto;
|
||||||
const normalizedPhone = normalizePhone(phone);
|
const normalizedPhone = normalizePhone(phone);
|
||||||
let admin: Admin | null = null;
|
let admin: Admin | null = null;
|
||||||
@@ -57,7 +57,7 @@ export class AdminService {
|
|||||||
const role = await this.em.findOne(Role, { id: roleId, isSystem: false });
|
const role = await this.em.findOne(Role, { id: roleId, isSystem: false });
|
||||||
if (!role) throw new NotFoundException('Role not found');
|
if (!role) throw new NotFoundException('Role not found');
|
||||||
|
|
||||||
const restaurant = await this.em.findOne(Restaurant, { id: restId });
|
const restaurant = await this.em.findOne(Restaurant, { id: });
|
||||||
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
||||||
|
|
||||||
let adminRole = await this.adminRoleRepository.findOne({
|
let adminRole = await this.adminRoleRepository.findOne({
|
||||||
@@ -79,7 +79,7 @@ export class AdminService {
|
|||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAdminForRestaurantBySuperAdmin(restId: string, dto: CreateMyRestaurantAdminDto) {
|
async createAdminForRestaurantBySuperAdmin(: string, dto: CreateMyRestaurantAdminDto) {
|
||||||
const { phone, firstName, lastName, roleId } = dto;
|
const { phone, firstName, lastName, roleId } = dto;
|
||||||
const normalizedPhone = normalizePhone(phone);
|
const normalizedPhone = normalizePhone(phone);
|
||||||
let admin: Admin | null = null;
|
let admin: Admin | null = null;
|
||||||
@@ -97,7 +97,7 @@ export class AdminService {
|
|||||||
const role = await this.em.findOne(Role, { id: roleId });
|
const role = await this.em.findOne(Role, { id: roleId });
|
||||||
if (!role) throw new NotFoundException('Role* not found' + roleId);
|
if (!role) throw new NotFoundException('Role* not found' + roleId);
|
||||||
|
|
||||||
const restaurant = await this.em.findOne(Restaurant, { id: restId });
|
const restaurant = await this.em.findOne(Restaurant, { id: });
|
||||||
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
||||||
|
|
||||||
let adminRole = await this.adminRoleRepository.findOne({
|
let adminRole = await this.adminRoleRepository.findOne({
|
||||||
@@ -119,16 +119,16 @@ export class AdminService {
|
|||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAdminsForRestaurantBySuperAdmin(restId: string): Promise<Admin[]> {
|
async getAdminsForRestaurantBySuperAdmin(: string): Promise<Admin[]> {
|
||||||
const restaurant = await this.em.findOne(Restaurant, { id: restId });
|
const restaurant = await this.em.findOne(Restaurant, { id: });
|
||||||
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
||||||
|
|
||||||
return this.adminRepository.find({ roles: { restaurant: restaurant } }, { populate: ['roles', 'roles.role'] });
|
return this.adminRepository.find({ roles: { restaurant: restaurant } }, { populate: ['roles', 'roles.role'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(adminId: string, restId: string, dto: UpdateAdminDto): Promise<Admin> {
|
async update(adminId: string, : string, dto: UpdateAdminDto): Promise<Admin> {
|
||||||
const admin = await this.adminRepository.findOne(
|
const admin = await this.adminRepository.findOne(
|
||||||
{ id: adminId, roles: { restaurant: { id: restId } } },
|
{ id: adminId, roles: { restaurant: { id: } } },
|
||||||
{ populate: ['roles', 'roles.role', 'roles.restaurant'] },
|
{ populate: ['roles', 'roles.role', 'roles.restaurant'] },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -164,14 +164,14 @@ export class AdminService {
|
|||||||
// Find existing AdminRole for this admin and restaurant
|
// Find existing AdminRole for this admin and restaurant
|
||||||
const existingAdminRole = await this.em.findOne(AdminRole, {
|
const existingAdminRole = await this.em.findOne(AdminRole, {
|
||||||
admin: { id: adminId },
|
admin: { id: adminId },
|
||||||
restaurant: { id: restId },
|
restaurant: { id: },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingAdminRole) {
|
if (existingAdminRole) {
|
||||||
// Update existing role
|
// Update existing role
|
||||||
existingAdminRole.role = role;
|
existingAdminRole.role = role;
|
||||||
} else {
|
} else {
|
||||||
const restaurant = await this.em.findOne(Restaurant, { id: restId });
|
const restaurant = await this.em.findOne(Restaurant, { id: });
|
||||||
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
||||||
// Create new AdminRole
|
// Create new AdminRole
|
||||||
const newAdminRole = this.em.create(AdminRole, {
|
const newAdminRole = this.em.create(AdminRole, {
|
||||||
@@ -187,8 +187,8 @@ export class AdminService {
|
|||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(adminId: string, restId: string): Promise<void> {
|
async remove(adminId: string, : string): Promise<void> {
|
||||||
const adminRole = await this.adminRoleRepository.findOne({ admin: { id: adminId }, restaurant: { id: restId } });
|
const adminRole = await this.adminRoleRepository.findOne({ admin: { id: adminId }, restaurant: { id: } });
|
||||||
if (!adminRole) {
|
if (!adminRole) {
|
||||||
throw new NotFoundException('Admin role not found');
|
throw new NotFoundException('Admin role not found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ export class AdminRepository extends EntityRepository<Admin> {
|
|||||||
|
|
||||||
return adminRole.admin;
|
return adminRole.admin;
|
||||||
}
|
}
|
||||||
async findAdminsWithPermission(restaurantId: string, permission: string): Promise<Admin[]> {
|
async findAdminsWithPermission(: string, permission: string): Promise<Admin[]> {
|
||||||
const admins = await this.em.find(
|
const admins = await this.em.find(
|
||||||
Admin,
|
Admin,
|
||||||
{ roles: { restaurant: { id: restaurantId }, role: { permissions: { name: permission } } } },
|
{ roles: { restaurant: { id: }, role: { permissions: { name: permission } } } },
|
||||||
{ populate: ['roles', 'roles.role', 'roles.role.permissions'] },
|
{ populate: ['roles', 'roles.role', 'roles.role.permissions'] },
|
||||||
);
|
);
|
||||||
return admins;
|
return admins;
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,4 @@ export class RequestOtpDto {
|
|||||||
@IsMobilePhone('fa-IR')
|
@IsMobilePhone('fa-IR')
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
@ApiProperty({ example: 'zhivan', description: 'restaurant slug' })
|
|
||||||
slug: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,4 @@ export class VerifyOtpDto {
|
|||||||
@Length(5)
|
@Length(5)
|
||||||
otp: string;
|
otp: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
@ApiProperty({ example: 'zhivan', description: 'restaurant slug' })
|
|
||||||
slug: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export enum RefreshTokenType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ tableName: 'refreshtokens' })
|
@Entity({ tableName: 'refreshtokens' })
|
||||||
@Index({ properties: ['ownerId', 'restId', 'type'] })
|
@Index({ properties: ['ownerId', '', 'type'] })
|
||||||
@Index({ properties: ['hashedToken'] })
|
@Index({ properties: ['hashedToken'] })
|
||||||
@Index({ properties: ['expiresAt'] })
|
@Index({ properties: ['expiresAt'] })
|
||||||
export class RefreshToken extends BaseEntity {
|
export class RefreshToken extends BaseEntity {
|
||||||
@@ -19,11 +19,11 @@ export class RefreshToken extends BaseEntity {
|
|||||||
ownerId!: string;
|
ownerId!: string;
|
||||||
|
|
||||||
@Property()
|
@Property()
|
||||||
restId!: string;
|
!: string;
|
||||||
|
|
||||||
@Enum(() => RefreshTokenType)
|
@Enum(() => RefreshTokenType)
|
||||||
type!: RefreshTokenType;
|
type!: RefreshTokenType;
|
||||||
|
|
||||||
@Property({ type: 'timestamptz' })
|
@Property({ type: 'timestamptz' })
|
||||||
expiresAt!: Date;
|
expiresAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class AdminAuthGuard implements CanActivate {
|
|||||||
secret,
|
secret,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payload.adminId || !payload.restId) {
|
if (!payload.adminId || !payload.) {
|
||||||
this.logger.error('Invalid token payload structure', payload);
|
this.logger.error('Invalid token payload structure', payload);
|
||||||
throw new UnauthorizedException('Invalid token payload');
|
throw new UnauthorizedException('Invalid token payload');
|
||||||
}
|
}
|
||||||
@@ -70,9 +70,9 @@ export class AdminAuthGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminPermission = await this.permissionsService.getAdminPermissions(payload.adminId, payload.restId);
|
const adminPermission = await this.permissionsService.getAdminPermissions(payload.adminId, payload.);
|
||||||
if (!adminPermission || !Array.isArray(adminPermission)) {
|
if (!adminPermission || !Array.isArray(adminPermission)) {
|
||||||
this.logger.error('No permissions found', { adminId: payload.adminId, restId: payload.restId });
|
this.logger.error('No permissions found', { adminId: payload.adminId, : payload. });
|
||||||
throw new ForbiddenException('No permissions found');
|
throw new ForbiddenException('No permissions found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export class AdminAuthGuard implements CanActivate {
|
|||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
this.logger.warn('Insufficient permissions', {
|
this.logger.warn('Insufficient permissions', {
|
||||||
adminId: payload.adminId,
|
adminId: payload.adminId,
|
||||||
restId: payload.restId,
|
: payload.,
|
||||||
required: requiredPermissions,
|
required: requiredPermissions,
|
||||||
has: adminPermission,
|
has: adminPermission,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
@Inject(ConfigService)
|
@Inject(ConfigService)
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request = context.switchToHttp().getRequest<UserAuthRequest>();
|
const request = context.switchToHttp().getRequest<UserAuthRequest>();
|
||||||
@@ -25,7 +25,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
try {
|
try {
|
||||||
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
|
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
|
||||||
const token = this.extractTokenFromHeader(request);
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
@@ -34,13 +34,13 @@ export class AuthGuard implements CanActivate {
|
|||||||
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
|
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
|
||||||
secret,
|
secret,
|
||||||
});
|
});
|
||||||
if (!payload.userId || !payload.restId) {
|
if (!payload.userId || !payload.) {
|
||||||
this.logger.error('Invalid token payload structure', payload);
|
this.logger.error('Invalid token payload structure', payload);
|
||||||
throw new UnauthorizedException('Invalid token payload');
|
throw new UnauthorizedException('Invalid token payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
request['userId'] = payload.userId;
|
request['userId'] = payload.userId;
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
@@ -58,5 +58,5 @@ export class AuthGuard implements CanActivate {
|
|||||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
return type === 'Bearer' ? token : undefined;
|
return type === 'Bearer' ? token : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
export interface ITokenPayload {
|
export interface ITokenPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
restId: string;
|
: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAdminTokenPayload {
|
export interface IAdminTokenPayload {
|
||||||
adminId: string;
|
adminId: string;
|
||||||
restId: string;
|
: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ export class TokensService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async generateAccessAndRefreshToken(
|
async generateAccessAndRefreshToken(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
restaurantId: string,
|
: string,
|
||||||
isAdmin: boolean,
|
isAdmin: boolean,
|
||||||
slug: string,
|
slug: string,
|
||||||
em?: EntityManager,
|
em?: EntityManager,
|
||||||
@@ -31,15 +31,15 @@ export class TokensService {
|
|||||||
const accessExpire = this.configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
|
const accessExpire = this.configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
|
||||||
|
|
||||||
const payload: ITokenPayload | IAdminTokenPayload = isAdmin
|
const payload: ITokenPayload | IAdminTokenPayload = isAdmin
|
||||||
? { adminId: ownerId, restId: restaurantId }
|
? { adminId: ownerId, : }
|
||||||
: { userId: ownerId, restId: restaurantId, slug };
|
: { userId: ownerId, : , slug };
|
||||||
|
|
||||||
const accessToken = await this.generateAccessToken(payload, accessExpire);
|
const accessToken = await this.generateAccessToken(payload, accessExpire);
|
||||||
const refreshToken = this.generateRefreshToken();
|
const refreshToken = this.generateRefreshToken();
|
||||||
const type = isAdmin ? RefreshTokenType.ADMIN : RefreshTokenType.USER;
|
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
|
// 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);
|
await this.storeRefreshToken(ownerId, , refreshToken, type, em);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'seconds').valueOf() },
|
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'seconds').valueOf() },
|
||||||
@@ -56,7 +56,7 @@ export class TokensService {
|
|||||||
|
|
||||||
async storeRefreshToken(
|
async storeRefreshToken(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
restId: string,
|
: string,
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
type: RefreshTokenType,
|
type: RefreshTokenType,
|
||||||
em?: EntityManager,
|
em?: EntityManager,
|
||||||
@@ -70,76 +70,76 @@ export class TokensService {
|
|||||||
const token = entityManager.create(RefreshToken, {
|
const token = entityManager.create(RefreshToken, {
|
||||||
hashedToken,
|
hashedToken,
|
||||||
ownerId,
|
ownerId,
|
||||||
restId,
|
,
|
||||||
type,
|
type,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (em) {
|
if(em) {
|
||||||
// Within transaction, just persist (flush will be called by transaction)
|
// Within transaction, just persist (flush will be called by transaction)
|
||||||
entityManager.persist(token);
|
entityManager.persist(token);
|
||||||
} else {
|
} else {
|
||||||
// Outside transaction, persist and flush immediately
|
// Outside transaction, persist and flush immediately
|
||||||
await entityManager.persistAndFlush(token);
|
await entityManager.persistAndFlush(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken(oldRefreshToken: string) {
|
async refreshToken(oldRefreshToken: string) {
|
||||||
const hashedToken = this.hashToken(oldRefreshToken);
|
const hashedToken = this.hashToken(oldRefreshToken);
|
||||||
|
|
||||||
// Use transaction to ensure atomicity and prevent race conditions
|
// Use transaction to ensure atomicity and prevent race conditions
|
||||||
return this.em.transactional(async em => {
|
return this.em.transactional(async em => {
|
||||||
// Lock the token row to prevent concurrent refresh attempts
|
// Lock the token row to prevent concurrent refresh attempts
|
||||||
// Using pessimistic write lock (FOR UPDATE) to prevent race conditions
|
// Using pessimistic write lock (FOR UPDATE) to prevent race conditions
|
||||||
const token = await em.findOne(RefreshToken, { hashedToken }, { lockMode: LockMode.PESSIMISTIC_WRITE });
|
const token = await em.findOne(RefreshToken, { hashedToken }, { lockMode: LockMode.PESSIMISTIC_WRITE });
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check expiration
|
// Check expiration
|
||||||
if (dayjs(token.expiresAt).isBefore(dayjs())) {
|
if (dayjs(token.expiresAt).isBefore(dayjs())) {
|
||||||
em.remove(token);
|
em.remove(token);
|
||||||
await em.flush();
|
await em.flush();
|
||||||
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
|
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store token data before removal
|
// Store token data before removal
|
||||||
const ownerId = token.ownerId;
|
const ownerId = token.ownerId;
|
||||||
const restId = token.restId;
|
const = token.;
|
||||||
const isAdmin = token.type === RefreshTokenType.ADMIN;
|
const isAdmin = token.type === RefreshTokenType.ADMIN;
|
||||||
|
|
||||||
// Verify restaurant still exists
|
// Verify restaurant still exists
|
||||||
const restaurant = await em.findOne(Restaurant, { id: restId });
|
const restaurant = await em.findOne(Restaurant, { id: });
|
||||||
if (!restaurant) {
|
if (!restaurant) {
|
||||||
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate new tokens first (before removing old token)
|
// Generate new tokens first (before removing old token)
|
||||||
// Pass the transaction EntityManager to ensure operations are within the transaction
|
// Pass the transaction EntityManager to ensure operations are within the transaction
|
||||||
const tokens = await this.generateAccessAndRefreshToken(ownerId, restaurant.id, isAdmin, restaurant.slug, em);
|
const tokens = await this.generateAccessAndRefreshToken(ownerId, restaurant.id, isAdmin, restaurant.slug, em);
|
||||||
|
|
||||||
// Remove old token only after new token is created
|
// Remove old token only after new token is created
|
||||||
em.remove(token);
|
em.remove(token);
|
||||||
|
|
||||||
// Persist changes atomically
|
// Persist changes atomically
|
||||||
await em.flush();
|
await em.flush();
|
||||||
|
|
||||||
return tokens;
|
return tokens;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to generate new tokens after refresh', error);
|
this.logger.error('Failed to generate new tokens after refresh', error);
|
||||||
// Transaction will rollback automatically, preserving the old token
|
// Transaction will rollback automatically, preserving the old token
|
||||||
throw new UnauthorizedException('Failed to refresh token');
|
throw new UnauthorizedException('Failed to refresh token');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateRefreshToken() {
|
private generateRefreshToken() {
|
||||||
return randomBytes(32).toString('hex');
|
return randomBytes(32).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
private hashToken(token: string): string {
|
private hashToken(token: string): string {
|
||||||
return createHash('sha256').update(token).digest('hex');
|
return createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { NotificationPreferenceService } from '../services/notification-preferen
|
|||||||
import { AuthGuard } from '../../auth/guards/auth.guard';
|
import { AuthGuard } from '../../auth/guards/auth.guard';
|
||||||
import { UserId } from '../../../common/decorators/user-id.decorator';
|
import { UserId } from '../../../common/decorators/user-id.decorator';
|
||||||
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
||||||
import { RestId } from '../../../common/decorators/rest-id.decorator';
|
import { } from '../../../common/decorators/rest-id.decorator';
|
||||||
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
|
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
|
||||||
import { CreatePreferenceDto } from '../dto/create-preference.dto';
|
import { CreatePreferenceDto } from '../dto/create-preference.dto';
|
||||||
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
||||||
@@ -38,14 +38,14 @@ export class NotificationsController {
|
|||||||
@ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' })
|
@ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' })
|
||||||
async getUserNotifications(
|
async getUserNotifications(
|
||||||
@UserId() userId: string,
|
@UserId() userId: string,
|
||||||
@RestId() restaurantId: string,
|
,
|
||||||
@Query('limit') limit?: number,
|
@Query('limit') limit?: number,
|
||||||
@Query('cursor') cursor?: string,
|
@Query('cursor') cursor?: string,
|
||||||
@Query('status') status?: 'seen' | 'unseen',
|
@Query('status') status?: 'seen' | 'unseen',
|
||||||
) {
|
) {
|
||||||
return await this.notificationService.findByUserAndRestaurant(
|
return await this.notificationService.findByUserAndRestaurant(
|
||||||
userId,
|
userId,
|
||||||
restaurantId,
|
,
|
||||||
limit ? parseInt(limit.toString(), 10) : 50,
|
limit ? parseInt(limit.toString(), 10) : 50,
|
||||||
cursor,
|
cursor,
|
||||||
status,
|
status,
|
||||||
@@ -57,8 +57,8 @@ export class NotificationsController {
|
|||||||
@Get('public/notifications/unseen-count')
|
@Get('public/notifications/unseen-count')
|
||||||
@ApiOperation({ summary: 'Get unseen notifications count for user' })
|
@ApiOperation({ summary: 'Get unseen notifications count for user' })
|
||||||
|
|
||||||
async getUserUnseenCount(@UserId() userId: string, @RestId() restaurantId: string) {
|
async getUserUnseenCount(@UserId() userId: string,) {
|
||||||
const count = await this.notificationService.countUnseenByUserAndRestaurant(userId, restaurantId);
|
const count = await this.notificationService.countUnseenByUserAndRestaurant(userId,);
|
||||||
return { count };
|
return { count };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +67,8 @@ export class NotificationsController {
|
|||||||
@Put('public/notifications/:id')
|
@Put('public/notifications/:id')
|
||||||
@ApiOperation({ summary: 'Read a notification ' })
|
@ApiOperation({ summary: 'Read a notification ' })
|
||||||
@ApiParam({ name: 'id', description: 'Notification ID' })
|
@ApiParam({ name: 'id', description: 'Notification ID' })
|
||||||
async readNotificationUser(@RestId() restaurantId: string, @Param('id') id: string, @UserId() userId: string) {
|
async readNotificationUser(, @Param('id') id: string, @UserId() userId: string) {
|
||||||
await this.notificationService.readNotificationAsUser(id, userId, restaurantId);
|
await this.notificationService.readNotificationAsUser(id, userId,);
|
||||||
return { message: NotificationMessage.READ_SUCCESS };
|
return { message: NotificationMessage.READ_SUCCESS };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +76,8 @@ export class NotificationsController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Put('public/notifications/read/all')
|
@Put('public/notifications/read/all')
|
||||||
@ApiOperation({ summary: 'Read all notification ' })
|
@ApiOperation({ summary: 'Read all notification ' })
|
||||||
async readAllNotificationUser(@RestId() restaurantId: string, @UserId() userId: string) {
|
async readAllNotificationUser(, @UserId() userId: string) {
|
||||||
await this.notificationService.readAllNotifsAsUser(userId, restaurantId);
|
await this.notificationService.readAllNotifsAsUser(userId,);
|
||||||
return { message: NotificationMessage.READ_SUCCESS };
|
return { message: NotificationMessage.READ_SUCCESS };
|
||||||
}
|
}
|
||||||
/* ***************** Admin Endpoints ***************** */
|
/* ***************** Admin Endpoints ***************** */
|
||||||
@@ -96,21 +96,21 @@ export class NotificationsController {
|
|||||||
@ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' })
|
@ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' })
|
||||||
async getAdminNotifications(
|
async getAdminNotifications(
|
||||||
@AdminId() adminId: string,
|
@AdminId() adminId: string,
|
||||||
@RestId() restaurantId: string,
|
,
|
||||||
@Query('limit') limit?: number,
|
@Query('limit') limit?: number,
|
||||||
@Query('cursor') cursor?: string,
|
@Query('cursor') cursor?: string,
|
||||||
@Query('status') status?: 'seen' | 'unseen',
|
@Query('status') status?: 'seen' | 'unseen',
|
||||||
) {
|
) {
|
||||||
const parsedLimit = limit ? parseInt(limit.toString(), 10) : 50;
|
const parsedLimit = limit ? parseInt(limit.toString(), 10) : 50;
|
||||||
return await this.notificationService.findByRestaurant(restaurantId, adminId, parsedLimit, cursor, status);
|
return await this.notificationService.findByRestaurant(, adminId, parsedLimit, cursor, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Get('admin/notifications/unseen-count')
|
@Get('admin/notifications/unseen-count')
|
||||||
@ApiOperation({ summary: 'Get unseen notifications count for admin' })
|
@ApiOperation({ summary: 'Get unseen notifications count for admin' })
|
||||||
async getAdminUnseenCount(@AdminId() adminId: string, @RestId() restaurantId: string) {
|
async getAdminUnseenCount(@AdminId() adminId: string,) {
|
||||||
const count = await this.notificationService.countUnseenByRestaurant(adminId, restaurantId);
|
const count = await this.notificationService.countUnseenByRestaurant(adminId,);
|
||||||
return { count };
|
return { count };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,8 +119,8 @@ export class NotificationsController {
|
|||||||
@Put('admin/notifications/:id')
|
@Put('admin/notifications/:id')
|
||||||
@ApiOperation({ summary: 'Read a notification ' })
|
@ApiOperation({ summary: 'Read a notification ' })
|
||||||
@ApiParam({ name: 'id', description: 'Notification ID' })
|
@ApiParam({ name: 'id', description: 'Notification ID' })
|
||||||
async readNotificationAdmin(@RestId() restaurantId: string, @AdminId() adminId: string, @Param('id') id: string) {
|
async readNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) {
|
||||||
await this.notificationService.readNotificationAdmin(id, adminId, restaurantId);
|
await this.notificationService.readNotificationAdmin(id, adminId,);
|
||||||
return { message: NotificationMessage.READ_SUCCESS };
|
return { message: NotificationMessage.READ_SUCCESS };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,8 +128,8 @@ export class NotificationsController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Put('admin/notifications/read/all')
|
@Put('admin/notifications/read/all')
|
||||||
@ApiOperation({ summary: 'Read all notification ' })
|
@ApiOperation({ summary: 'Read all notification ' })
|
||||||
async readAllNotificationAdmin(@RestId() restaurantId: string, @AdminId() adminId: string, @Param('id') id: string) {
|
async readAllNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) {
|
||||||
await this.notificationService.readAllNotifsAsAdmin(adminId, restaurantId);
|
await this.notificationService.readAllNotifsAsAdmin(adminId,);
|
||||||
return { message: NotificationMessage.READ_SUCCESS };
|
return { message: NotificationMessage.READ_SUCCESS };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +138,8 @@ export class NotificationsController {
|
|||||||
@Permissions(Permission.MANAGE_SETTINGS)
|
@Permissions(Permission.MANAGE_SETTINGS)
|
||||||
@Get('admin/notification-preferences')
|
@Get('admin/notification-preferences')
|
||||||
@ApiOperation({ summary: 'Get all notification preferences for a restaurant' })
|
@ApiOperation({ summary: 'Get all notification preferences for a restaurant' })
|
||||||
async getPreferences(@RestId() restaurantId: string) {
|
async getPreferences() {
|
||||||
return this.preferenceService.findByRestaurant(restaurantId);
|
return this.preferenceService.findByRestaurant();
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@@ -149,11 +149,11 @@ export class NotificationsController {
|
|||||||
@ApiOperation({ summary: 'Update notification channels' })
|
@ApiOperation({ summary: 'Update notification channels' })
|
||||||
@ApiParam({ name: 'id', description: 'Notification preference ID' })
|
@ApiParam({ name: 'id', description: 'Notification preference ID' })
|
||||||
async updatePreference(
|
async updatePreference(
|
||||||
@RestId() restaurantId: string,
|
,
|
||||||
@Param('id') preferenceId: string,
|
@Param('id') preferenceId: string,
|
||||||
@Body() dto: UpdatePreferenceDto,
|
@Body() dto: UpdatePreferenceDto,
|
||||||
) {
|
) {
|
||||||
return this.preferenceService.updatePreference(preferenceId, restaurantId, dto);
|
return this.preferenceService.updatePreference(preferenceId, , dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@@ -162,18 +162,18 @@ export class NotificationsController {
|
|||||||
@Post('admin/notification-preferences')
|
@Post('admin/notification-preferences')
|
||||||
@ApiOperation({ summary: 'Create notification preference' })
|
@ApiOperation({ summary: 'Create notification preference' })
|
||||||
async createPreference(
|
async createPreference(
|
||||||
@RestId() restaurantId: string,
|
,
|
||||||
@Body() dto: CreatePreferenceDto,
|
@Body() dto: CreatePreferenceDto,
|
||||||
) {
|
) {
|
||||||
return this.preferenceService.create(restaurantId, dto);
|
return this.preferenceService.create(, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Get('admin/notifications/sms-usage')
|
@Get('admin/notifications/sms-usage')
|
||||||
@ApiOperation({ summary: 'Get SMS usage for my restaurant' })
|
@ApiOperation({ summary: 'Get SMS usage for my restaurant' })
|
||||||
async getRestaurantSmsUsage(@RestId() restaurantId: string) {
|
async getRestaurantSmsUsage() {
|
||||||
const smsCount = await this.notificationService.getSmsCountByRestaurantId(restaurantId);
|
const smsCount = await this.notificationService.getSmsCountBy();
|
||||||
return { smsCount };
|
return { smsCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ import type { AuthenticatedSocket } from '../guards/ws-admin-auth.guard';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract restaurant ID from authenticated WebSocket client
|
* Extract restaurant ID from authenticated WebSocket client
|
||||||
* Use this decorator in WebSocket handlers to get the restId from the authenticated admin
|
* Use this decorator in WebSocket handlers to get the from the authenticated admin
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* @SubscribeMessage('get:notifications')
|
* @SubscribeMessage('get:notifications')
|
||||||
* handleGetNotifications(@WsRestId() restId: string) {
|
* handleGetNotifications(@Ws() : string) {
|
||||||
* // restId is automatically extracted from authenticated admin
|
* // is automatically extracted from authenticated admin
|
||||||
* }
|
* }
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const WsRestId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
|
export const Ws = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
|
||||||
const client = ctx.switchToWs().getClient<AuthenticatedSocket>();
|
const client = ctx.switchToWs().getClient<AuthenticatedSocket>();
|
||||||
const restId = client.restId;
|
const = client.;
|
||||||
|
|
||||||
if (!restId) {
|
if (!) {
|
||||||
throw new Error('Restaurant ID not found. Ensure WsAdminAuthGuard is applied.');
|
throw new Error('Restaurant ID not found. Ensure WsAdminAuthGuard is applied.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return restId;
|
return;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export class SendNotificationDto {
|
|||||||
@ApiProperty({ description: 'Restaurant ID (ULID)' })
|
@ApiProperty({ description: 'Restaurant ID (ULID)' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
restaurantId: string;
|
: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'User ID (ULID)' })
|
@ApiPropertyOptional({ description: 'User ID (ULID)' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { Entity, Property, ManyToOne, Enum } from '@mikro-orm/core';
|
import { Entity, Property, ManyToOne, Enum } from '@mikro-orm/core';
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
|
||||||
import { User } from '../../user/entities/user.entity';
|
import { User } from '../../user/entities/user.entity';
|
||||||
import { NotifTitleEnum } from '../interfaces/notification.interface';
|
import { NotifTitleEnum } from '../interfaces/notification.interface';
|
||||||
import { Admin } from 'src/modules/admin/entities/admin.entity';
|
import { Admin } from 'src/modules/admin/entities/admin.entity';
|
||||||
|
|
||||||
@Entity({ tableName: 'notifications' })
|
@Entity({ tableName: 'notifications' })
|
||||||
export class Notification extends BaseEntity {
|
export class Notification extends BaseEntity {
|
||||||
@ManyToOne(() => Restaurant)
|
|
||||||
restaurant!: Restaurant;
|
|
||||||
|
|
||||||
@ManyToOne(() => User, { nullable: true })
|
@ManyToOne(() => User, { nullable: true })
|
||||||
user?: User;
|
user?: User;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export class SmsSentEvent {
|
export class SmsSentEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly phoneNumber: string,
|
public readonly phoneNumber: string,
|
||||||
public readonly restaurantId: string,
|
) { }
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { IAdminTokenPayload } from '../../auth/interfaces/IToken-payload';
|
|||||||
|
|
||||||
export interface AuthenticatedSocket extends Socket {
|
export interface AuthenticatedSocket extends Socket {
|
||||||
adminId?: string;
|
adminId?: string;
|
||||||
restId?: string;
|
?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -19,7 +19,7 @@ export class WsAdminAuthGuard implements CanActivate {
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
@Inject(ConfigService)
|
@Inject(ConfigService)
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const client: Socket = context.switchToWs().getClient<Socket>();
|
const client: Socket = context.switchToWs().getClient<Socket>();
|
||||||
@@ -41,16 +41,16 @@ export class WsAdminAuthGuard implements CanActivate {
|
|||||||
secret,
|
secret,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payload.adminId || !payload.restId) {
|
if (!payload.adminId || !payload.) {
|
||||||
this.logger.error('Invalid token payload structure', payload);
|
this.logger.error('Invalid token payload structure', payload);
|
||||||
throw new WsException('Invalid token payload');
|
throw new WsException('Invalid token payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach admin info to socket
|
// Attach admin info to socket
|
||||||
(client as AuthenticatedSocket).adminId = payload.adminId;
|
(client as AuthenticatedSocket).adminId = payload.adminId;
|
||||||
(client as AuthenticatedSocket).restId = payload.restId;
|
(client as AuthenticatedSocket). = payload.;
|
||||||
|
|
||||||
this.logger.log(`Admin authenticated via WebSocket: ${payload.adminId}, restId: ${payload.restId}`);
|
this.logger.log(`Admin authenticated via WebSocket: ${payload.adminId}, : ${payload.}`);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { NotifTitleEnum, recipientType } from './notification.interface';
|
|||||||
export interface SmsNotificationQueueJob {
|
export interface SmsNotificationQueueJob {
|
||||||
recipient: recipientType;
|
recipient: recipientType;
|
||||||
templateId: string;
|
templateId: string;
|
||||||
restaurantId: string;
|
: string;
|
||||||
parameters?: Record<string, string>;
|
parameters?: Record<string, string>;
|
||||||
}
|
}
|
||||||
export interface PushNotificationQueueJob {
|
export interface PushNotificationQueueJob {
|
||||||
@@ -18,7 +18,7 @@ export interface PushNotificationQueueJob {
|
|||||||
pushTokens?: string[]; // Multiple FCM tokens for bulk push notifications
|
pushTokens?: string[]; // Multiple FCM tokens for bulk push notifications
|
||||||
}
|
}
|
||||||
export interface InAppNotificationQueueJob {
|
export interface InAppNotificationQueueJob {
|
||||||
recipient: { adminId: string; restaurantId: string };
|
recipient: { adminId: string; : string };
|
||||||
subject: NotifTitleEnum;
|
subject: NotifTitleEnum;
|
||||||
body: string;
|
body: string;
|
||||||
notificationId: string;
|
notificationId: string;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface NotifRequest {
|
|||||||
// timestamp: Date;
|
// timestamp: Date;
|
||||||
// notifType: NotifTypeEnum;
|
// notifType: NotifTypeEnum;
|
||||||
// channels: NotifChannelEnum[];
|
// channels: NotifChannelEnum[];
|
||||||
restaurantId: string;
|
: string;
|
||||||
recipients: recipientType[];
|
recipients: recipientType[];
|
||||||
message: NotifRequestMessage;
|
message: NotifRequestMessage;
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ export class SmsListeners {
|
|||||||
async handleSmsSent(event: SmsSentEvent) {
|
async handleSmsSent(event: SmsSentEvent) {
|
||||||
try {
|
try {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`SMS sent event received: phone ${event.phoneNumber} for restaurant: ${event.restaurantId}`,
|
`SMS sent event received: phone ${event.phoneNumber} for restaurant: ${event.}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the restaurant entity
|
// Get the restaurant entity
|
||||||
const restaurant = await this.restRepository.findOne({ id: event.restaurantId });
|
const restaurant = await this.restRepository.findOne({ id: event. });
|
||||||
|
|
||||||
if (!restaurant) {
|
if (!restaurant) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Restaurant not found for SMS log: ${event.restaurantId}`,
|
`Restaurant not found for SMS log: ${event.}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ export class SmsListeners {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to create SMS log for event: ${event.restaurantId}`,
|
`Failed to create SMS log for event: ${event.}`,
|
||||||
error instanceof Error ? error.stack : String(error),
|
error instanceof Error ? error.stack : String(error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
@Inject(forwardRef(() => NotificationService))
|
@Inject(forwardRef(() => NotificationService))
|
||||||
private readonly notificationService: NotificationService,
|
private readonly notificationService: NotificationService,
|
||||||
private readonly moduleRef: ModuleRef,
|
private readonly moduleRef: ModuleRef,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async handleConnection(client: Socket) {
|
async handleConnection(client: Socket) {
|
||||||
try {
|
try {
|
||||||
@@ -70,8 +70,8 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
sendInAppNotification(repipient: { adminId: string; restaurantId: string }, payload: IInAppNotificationPayload) {
|
sendInAppNotification(repipient: { adminId: string; : string }, payload: IInAppNotificationPayload) {
|
||||||
const room = this.getRoom(repipient.adminId, repipient.restaurantId);
|
const room = this.getRoom(repipient.adminId, repipient.);
|
||||||
|
|
||||||
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`);
|
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`);
|
||||||
this.server.to(room).emit('notifications', payload, async () => {
|
this.server.to(room).emit('notifications', payload, async () => {
|
||||||
@@ -81,19 +81,19 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
this.logger.log(`In app notification sent to admin: ${repipient.adminId}`);
|
this.logger.log(`In app notification sent to admin: ${repipient.adminId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRoom(adminId: string, restaurantId: string) {
|
private getRoom(adminId: string, : string) {
|
||||||
return `restaurant:${restaurantId}-admin:${adminId}`;
|
return `restaurant:${}-admin:${adminId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLeaveRoom(client: AuthenticatedSocket) {
|
handleLeaveRoom(client: AuthenticatedSocket) {
|
||||||
const room = this.getRoom(client.adminId!, client.restId!);
|
const room = this.getRoom(client.adminId!, client.!);
|
||||||
void client.leave(room);
|
void client.leave(room);
|
||||||
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`);
|
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`);
|
||||||
void client.emit('left', { room, message: 'Successfully Admin left room' });
|
void client.emit('left', { room, message: 'Successfully Admin left room' });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleJoinRoom(client: AuthenticatedSocket) {
|
handleJoinRoom(client: AuthenticatedSocket) {
|
||||||
const room = this.getRoom(client.adminId!, client.restId!);
|
const room = this.getRoom(client.adminId!, client.!);
|
||||||
void client.join(room);
|
void client.join(room);
|
||||||
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
|
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
|
||||||
void client.emit('joined', { room, message: 'Successfully Admin joined room' });
|
void client.emit('joined', { room, message: 'Successfully Admin joined room' });
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class InAppProcessor extends WorkerHost {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.notificationsGateway.sendInAppNotification(
|
this.notificationsGateway.sendInAppNotification(
|
||||||
{ adminId: recipient.adminId, restaurantId: recipient.restaurantId },
|
{ adminId: recipient.adminId },
|
||||||
{ subject, body, notificationId },
|
{ subject, body, notificationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class SmsProcessor extends WorkerHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async process(job: Job<SmsNotificationQueueJob>) {
|
async process(job: Job<SmsNotificationQueueJob>) {
|
||||||
const { recipient, templateId, parameters, restaurantId } = job.data;
|
const { recipient, templateId, parameters, } = job.data;
|
||||||
|
|
||||||
this.logger.log(`Processing SMS notification - Recipient: ${JSON.stringify(recipient)}, templateID: ${templateId}`);
|
this.logger.log(`Processing SMS notification - Recipient: ${JSON.stringify(recipient)}, templateID: ${templateId}`);
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ export class SmsProcessor extends WorkerHost {
|
|||||||
|
|
||||||
this.logger.log(`SMS notification sent successfully to ${phone}`);
|
this.logger.log(`SMS notification sent successfully to ${phone}`);
|
||||||
|
|
||||||
this.eventEmitter.emit(SmsSentEvent.name, new SmsSentEvent(phone, restaurantId));
|
this.eventEmitter.emit(SmsSentEvent.name, new SmsSentEvent(phone));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -12,24 +12,25 @@ export class SmsLogRepository extends EntityRepository<SmsLog> {
|
|||||||
async getSmsCountByRestaurant(
|
async getSmsCountByRestaurant(
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 10,
|
limit: number = 10,
|
||||||
): Promise<PaginatedResult<{ restaurantId: string; restaurantName: string; smsCount: number }>> {
|
): Promise<PaginatedResult<{ : string; restaurantName: string; smsCount: number
|
||||||
const offset = (page - 1) * limit;
|
}>> {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
// Get total count of unique restaurants with SMS logs
|
// Get total count of unique restaurants with SMS logs
|
||||||
const totalResult = await this.em.execute(
|
const totalResult = await this.em.execute(
|
||||||
`
|
`
|
||||||
SELECT COUNT(DISTINCT sl.restaurant_id) as total
|
SELECT COUNT(DISTINCT sl.restaurant_id) as total
|
||||||
FROM sms_logs sl
|
FROM sms_logs sl
|
||||||
WHERE sl.restaurant_id IS NOT NULL
|
WHERE sl.restaurant_id IS NOT NULL
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
const total = parseInt(totalResult[0]?.total || '0', 10);
|
const total = parseInt(totalResult[0]?.total || '0', 10);
|
||||||
|
|
||||||
// Get paginated results with SMS counts grouped by restaurant
|
// Get paginated results with SMS counts grouped by restaurant
|
||||||
const results = await this.em.execute(
|
const results = await this.em.execute(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
r.id as "restaurantId",
|
r.id as "",
|
||||||
r.name as "restaurantName",
|
r.name as "restaurantName",
|
||||||
COUNT(sl.id)::int as "smsCount"
|
COUNT(sl.id)::int as "smsCount"
|
||||||
FROM sms_logs sl
|
FROM sms_logs sl
|
||||||
@@ -38,39 +39,39 @@ export class SmsLogRepository extends EntityRepository<SmsLog> {
|
|||||||
ORDER BY "smsCount" DESC, r.name ASC
|
ORDER BY "smsCount" DESC, r.name ASC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`,
|
`,
|
||||||
[limit, offset],
|
[limit, offset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = results.map((row: any) => ({
|
const data = results.map((row: any) => ({
|
||||||
restaurantId: row.restaurantId,
|
: row.,
|
||||||
restaurantName: row.restaurantName || 'Unknown',
|
restaurantName: row.restaurantName || 'Unknown',
|
||||||
smsCount: parseInt(row.smsCount || '0', 10),
|
smsCount: parseInt(row.smsCount || '0', 10),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
meta: {
|
meta: {
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
totalPages,
|
totalPages,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
|
async getSmsCountBy(: string): Promise < number > {
|
||||||
const result = await this.em.execute(
|
const result = await this.em.execute(
|
||||||
`
|
`
|
||||||
SELECT COUNT(sl.id)::int as "smsCount"
|
SELECT COUNT(sl.id)::int as "smsCount"
|
||||||
FROM sms_logs sl
|
FROM sms_logs sl
|
||||||
WHERE sl.restaurant_id = ?
|
WHERE sl.restaurant_id = ?
|
||||||
`,
|
`,
|
||||||
[restaurantId],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return parseInt(result[0]?.smsCount || '0', 10);
|
return parseInt(result[0]?.smsCount || '0', 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { NotifTitleEnum } from '../interfaces/notification.interface';
|
|||||||
export class NotificationPreferenceService {
|
export class NotificationPreferenceService {
|
||||||
constructor(private readonly em: EntityManager) { }
|
constructor(private readonly em: EntityManager) { }
|
||||||
|
|
||||||
async create(restaurantId: string, dto: CreatePreferenceDto): Promise<NotificationPreference> {
|
async create(: string, dto: CreatePreferenceDto): Promise<NotificationPreference> {
|
||||||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
const restaurant = await this.em.findOne(Restaurant, { id: });
|
||||||
if (!restaurant) {
|
if (!restaurant) {
|
||||||
throw new NotFoundException('Restaurant not found');
|
throw new NotFoundException('Restaurant not found');
|
||||||
}
|
}
|
||||||
@@ -26,25 +26,25 @@ export class NotificationPreferenceService {
|
|||||||
return preference;
|
return preference;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByRestaurant(restaurantId: string): Promise<NotificationPreference[]> {
|
async findByRestaurant(: string): Promise<NotificationPreference[]> {
|
||||||
return this.em.find(NotificationPreference, {
|
return this.em.find(NotificationPreference, {
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByRestaurantAndType(restaurantId: string, title: NotifTitleEnum): Promise<NotificationPreference | null> {
|
async findByRestaurantAndType(: string, title: NotifTitleEnum): Promise<NotificationPreference | null> {
|
||||||
return this.em.findOne(NotificationPreference, {
|
return this.em.findOne(NotificationPreference, {
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
title,
|
title,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// async updateEnabled(
|
// async updateEnabled(
|
||||||
// restaurantId: string,
|
// : string,
|
||||||
// notificationType: string,
|
// notificationType: string,
|
||||||
// enabled: boolean,
|
// enabled: boolean,
|
||||||
// ): Promise<NotificationPreference> {
|
// ): Promise<NotificationPreference> {
|
||||||
// const preference = await this.findByRestaurantAndType(restaurantId, notificationType);
|
// const preference = await this.findByRestaurantAndType(, notificationType);
|
||||||
// if (!preference) {
|
// if (!preference) {
|
||||||
// throw new NotFoundException('Notification preference not found');
|
// throw new NotFoundException('Notification preference not found');
|
||||||
// }
|
// }
|
||||||
@@ -56,12 +56,12 @@ export class NotificationPreferenceService {
|
|||||||
|
|
||||||
async updatePreference(
|
async updatePreference(
|
||||||
preferenceId: string,
|
preferenceId: string,
|
||||||
restaurantId: string,
|
: string,
|
||||||
dto: UpdatePreferenceDto,
|
dto: UpdatePreferenceDto,
|
||||||
): Promise<NotificationPreference> {
|
): Promise<NotificationPreference> {
|
||||||
const preference = await this.em.findOne(NotificationPreference, {
|
const preference = await this.em.findOne(NotificationPreference, {
|
||||||
id: preferenceId,
|
id: preferenceId,
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
});
|
});
|
||||||
if (!preference) {
|
if (!preference) {
|
||||||
throw new NotFoundException('Notification preference not found');
|
throw new NotFoundException('Notification preference not found');
|
||||||
@@ -72,9 +72,9 @@ export class NotificationPreferenceService {
|
|||||||
return preference;
|
return preference;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(restaurantId: string, preferenceId: string): Promise<void> {
|
async delete(: string, preferenceId: string): Promise<void> {
|
||||||
const preference = await this.em.findOne(NotificationPreference, {
|
const preference = await this.em.findOne(NotificationPreference, {
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
id: preferenceId,
|
id: preferenceId,
|
||||||
});
|
});
|
||||||
if (!preference) {
|
if (!preference) {
|
||||||
|
|||||||
@@ -22,261 +22,258 @@ export class NotificationService {
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
async sendNotification(params: NotifRequest): Promise<Notification[]> {
|
async sendNotification(params: NotifRequest): Promise<Notification[]> {
|
||||||
const { recipients, message, metadata, restaurantId } = params;
|
const { recipients, message, metadata, } = params;
|
||||||
|
|
||||||
// create Database notifications
|
// create Database notifications
|
||||||
const notifications = await this.createAdminBulkNotifications(
|
const notifications = await this.createAdminBulkNotifications(
|
||||||
recipients.map(recipient => ({
|
recipients.map(recipient => ({
|
||||||
restaurantId,
|
title: message.title,
|
||||||
title: message.title,
|
|
||||||
content: message.content,
|
content: message.content,
|
||||||
adminId: 'adminId' in recipient ? recipient.adminId : null,
|
adminId: 'adminId' in recipient ? recipient.adminId : null,
|
||||||
userId: 'userId' in recipient ? recipient.userId : null,
|
userId: 'userId' in recipient ? recipient.userId : null,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
// get admin prefrences
|
// get admin prefrences
|
||||||
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, message.title);
|
const preference = await this.preferenceService.findByRestaurantAndType(, message.title);
|
||||||
|
|
||||||
if (preference?.channels?.length === 0) {
|
if(preference?.channels?.length === 0) {
|
||||||
this.logger.warn(`Notification type is NONE for restaurant ${restaurantId}, title ${message.title}`);
|
this.logger.warn(`Notification type is NONE for restaurant ${}, title ${message.title}`);
|
||||||
return notifications;
|
return notifications;
|
||||||
}
|
}
|
||||||
|
|
||||||
// send in app notification
|
// send in app notification
|
||||||
if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
|
if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
|
||||||
await this.queueService.addBulkInAppNotifications(
|
await this.queueService.addBulkInAppNotifications(
|
||||||
notifications.map(notification => ({
|
notifications.map(notification => ({
|
||||||
recipient: { adminId: notification.admin?.id || '', restaurantId },
|
recipient: { adminId: notification.admin?.id || '', },
|
||||||
subject: message.title,
|
subject: message.title,
|
||||||
body: message.content,
|
body: message.content,
|
||||||
notificationId: notification.id,
|
notificationId: notification.id,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// add sms notifications to queue
|
// add sms notifications to queue
|
||||||
if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
|
if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
|
||||||
await this.queueService.addBulkSmsNotifications(
|
await this.queueService.addBulkSmsNotifications(
|
||||||
recipients.map(recipient => ({
|
recipients.map(recipient => ({
|
||||||
recipient,
|
recipient,
|
||||||
templateId: message.sms.templateId,
|
templateId: message.sms.templateId,
|
||||||
parameters: message.sms.parameters,
|
parameters: message.sms.parameters,
|
||||||
restaurantId,
|
})),
|
||||||
})),
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Queued notification for restaurant ${restaurantId}, title ${message.title}`);
|
this.logger.log(`Queued notification for restaurant ${}, title ${message.title}`);
|
||||||
|
|
||||||
return notifications;
|
return notifications;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAdminBulkNotifications(
|
async createAdminBulkNotifications(
|
||||||
params: {
|
params: {
|
||||||
restaurantId: string;
|
|
||||||
title: NotifTitleEnum;
|
title: NotifTitleEnum;
|
||||||
content: string;
|
content: string;
|
||||||
adminId: string | null;
|
adminId: string | null;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
}[],
|
}[],
|
||||||
): Promise<Notification[]> {
|
): Promise < Notification[] > {
|
||||||
const notifications = params.map(param => {
|
const notifications = params.map(param => {
|
||||||
return this.em.create(Notification, {
|
return this.em.create(Notification, {
|
||||||
restaurant: param.restaurantId,
|
admin: param.adminId,
|
||||||
admin: param.adminId,
|
user: param.userId && param.userId.trim() !== '' ? param.userId : null,
|
||||||
user: param.userId && param.userId.trim() !== '' ? param.userId : null,
|
title: param.title,
|
||||||
title: param.title,
|
content: param.content,
|
||||||
content: param.content,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
await this.em.persistAndFlush(notifications);
|
});
|
||||||
return notifications;
|
await this.em.persistAndFlush(notifications);
|
||||||
}
|
return notifications;
|
||||||
|
}
|
||||||
|
|
||||||
async findOne(id: string): Promise<Notification> {
|
async findOne(id: string): Promise < Notification > {
|
||||||
const notification = await this.em.findOne(Notification, { id }, { populate: ['restaurant', 'user'] });
|
const notification = await this.em.findOne(Notification, { id }, { populate: ['restaurant', 'user'] });
|
||||||
if (!notification) {
|
if(!notification) {
|
||||||
throw new NotFoundException('Notification not found');
|
throw new NotFoundException('Notification not found');
|
||||||
}
|
|
||||||
return notification;
|
|
||||||
}
|
}
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
async findByRestaurant(
|
async findByRestaurant(
|
||||||
restaurantId: string,
|
: string,
|
||||||
adminId: string,
|
adminId: string,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
cursor?: string,
|
cursor ?: string,
|
||||||
status?: 'seen' | 'unseen',
|
status ?: 'seen' | 'unseen',
|
||||||
): Promise<{ data: Notification[]; nextCursor: string | null }> {
|
): Promise < { data: Notification[]; nextCursor: string | null } > {
|
||||||
const where: FilterQuery<Notification> = {
|
const where: FilterQuery<Notification> = {
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
admin: { id: adminId },
|
admin: { id: adminId },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter by status (seen/unseen)
|
// Filter by status (seen/unseen)
|
||||||
if (status === 'seen') {
|
if (status === 'seen') {
|
||||||
where.seenAt = { $ne: null };
|
where.seenAt = { $ne: null };
|
||||||
} else if (status === 'unseen') {
|
} else if (status === 'unseen') {
|
||||||
where.seenAt = null;
|
where.seenAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor-based pagination: if cursor is provided, get items with id < cursor
|
// Cursor-based pagination: if cursor is provided, get items with id < cursor
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
where.id = { $lt: cursor };
|
where.id = { $lt: cursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifications = await this.em.find(Notification, where, {
|
const notifications = await this.em.find(Notification, where, {
|
||||||
orderBy: { createdAt: 'DESC', id: 'DESC' },
|
orderBy: { createdAt: 'DESC', id: 'DESC' },
|
||||||
limit: limit + 1, // fetch one extra to determine next page
|
limit: limit + 1, // fetch one extra to determine next page
|
||||||
populate: ['user'],
|
populate: ['user'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasNextPage = notifications.length > limit;
|
const hasNextPage = notifications.length > limit;
|
||||||
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
||||||
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
nextCursor: nextCursor ?? null,
|
nextCursor: nextCursor ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserAndRestaurant(
|
async findByUserAndRestaurant(
|
||||||
userId: string,
|
userId: string,
|
||||||
restaurantId: string,
|
: string,
|
||||||
limit = 50,
|
limit = 50,
|
||||||
cursor?: string,
|
cursor ?: string,
|
||||||
status?: 'seen' | 'unseen',
|
status ?: 'seen' | 'unseen',
|
||||||
): Promise<{ data: Notification[]; nextCursor: string | null }> {
|
): Promise < { data: Notification[]; nextCursor: string | null } > {
|
||||||
const where: FilterQuery<Notification> = {
|
const where: FilterQuery<Notification> = {
|
||||||
user: { id: userId },
|
user: { id: userId },
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter by status (seen/unseen)
|
// Filter by status (seen/unseen)
|
||||||
if (status === 'seen') {
|
if (status === 'seen') {
|
||||||
where.seenAt = { $ne: null };
|
where.seenAt = { $ne: null };
|
||||||
} else if (status === 'unseen') {
|
} else if (status === 'unseen') {
|
||||||
where.seenAt = null;
|
where.seenAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor-based pagination: if cursor is provided, get items with id < cursor (since ULIDs are time-ordered)
|
// Cursor-based pagination: if cursor is provided, get items with id < cursor (since ULIDs are time-ordered)
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
where.id = { $lt: cursor };
|
where.id = { $lt: cursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifications = await this.em.find(Notification, where, {
|
const notifications = await this.em.find(Notification, where, {
|
||||||
orderBy: { createdAt: 'DESC', id: 'DESC' },
|
orderBy: { createdAt: 'DESC', id: 'DESC' },
|
||||||
limit: limit + 1, // Fetch one extra to determine if there's a next page
|
limit: limit + 1, // Fetch one extra to determine if there's a next page
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if there's a next page
|
// Check if there's a next page
|
||||||
const hasNextPage = notifications.length > limit;
|
const hasNextPage = notifications.length > limit;
|
||||||
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
||||||
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
nextCursor: nextCursor ?? null,
|
nextCursor: nextCursor ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async readNotificationAdmin(id: string, adminId: string, restaurantId: string): Promise<void> {
|
async readNotificationAdmin(id: string, adminId: string, : string): Promise < void> {
|
||||||
const notification = await this.em.findOne(Notification, {
|
const notification = await this.em.findOne(Notification, {
|
||||||
id,
|
id,
|
||||||
admin: { id: adminId },
|
admin: { id: adminId },
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
});
|
});
|
||||||
if (!notification) {
|
if(!notification) {
|
||||||
throw new NotFoundException('Notification not found');
|
throw new NotFoundException('Notification not found');
|
||||||
}
|
}
|
||||||
notification.seenAt = new Date();
|
notification.seenAt = new Date();
|
||||||
await this.em.persistAndFlush(notification);
|
await this.em.persistAndFlush(notification);
|
||||||
|
}
|
||||||
|
async readNotificationAsUser(id: string, userId: string, : string): Promise < void> {
|
||||||
|
const notification = await this.em.findOne(Notification, {
|
||||||
|
id,
|
||||||
|
user: { id: userId },
|
||||||
|
restaurant: { id: },
|
||||||
|
});
|
||||||
|
if(!notification) {
|
||||||
|
throw new NotFoundException('Notification not found');
|
||||||
}
|
}
|
||||||
async readNotificationAsUser(id: string, userId: string, restaurantId: string): Promise<void> {
|
|
||||||
const notification = await this.em.findOne(Notification, {
|
|
||||||
id,
|
|
||||||
user: { id: userId },
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
});
|
|
||||||
if (!notification) {
|
|
||||||
throw new NotFoundException('Notification not found');
|
|
||||||
}
|
|
||||||
notification.seenAt = new Date();
|
notification.seenAt = new Date();
|
||||||
await this.em.persistAndFlush(notification);
|
await this.em.persistAndFlush(notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByRestaurantAndType(restaurantId: string, title: NotifTitleEnum, limit = 50): Promise<Notification[]> {
|
async findByRestaurantAndType(: string, title: NotifTitleEnum, limit = 50): Promise < Notification[] > {
|
||||||
return this.em.find(
|
return this.em.find(
|
||||||
Notification,
|
Notification,
|
||||||
{
|
{
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
title,
|
title,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
orderBy: { createdAt: 'DESC' },
|
orderBy: { createdAt: 'DESC' },
|
||||||
limit,
|
limit,
|
||||||
populate: ['user'],
|
populate: ['user'],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByAdminAndRestaurant(adminId: string, restaurantId: string, limit = 50): Promise<Notification[]> {
|
async findByAdminAndRestaurant(adminId: string, : string, limit = 50): Promise < Notification[] > {
|
||||||
return this.em.find(
|
return this.em.find(
|
||||||
Notification,
|
Notification,
|
||||||
{ admin: { id: adminId }, restaurant: { id: restaurantId } },
|
{ admin: { id: adminId }, restaurant: { id: } },
|
||||||
{
|
{
|
||||||
orderBy: { createdAt: 'DESC' },
|
orderBy: { createdAt: 'DESC' },
|
||||||
limit,
|
limit,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async countUnseenByUserAndRestaurant(userId: string, restaurantId: string): Promise<number> {
|
async countUnseenByUserAndRestaurant(userId: string, : string): Promise < number > {
|
||||||
const where: FilterQuery<Notification> = {
|
const where: FilterQuery<Notification> = {
|
||||||
user: { id: userId },
|
user: { id: userId },
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
seenAt: null,
|
seenAt: null,
|
||||||
};
|
};
|
||||||
return this.em.count(Notification, where);
|
return this.em.count(Notification, where);
|
||||||
}
|
}
|
||||||
|
|
||||||
async countUnseenByRestaurant(adminId: string, restaurantId: string): Promise<number> {
|
async countUnseenByRestaurant(adminId: string, : string): Promise < number > {
|
||||||
const where: FilterQuery<Notification> = {
|
const where: FilterQuery<Notification> = {
|
||||||
admin: { id: adminId },
|
admin: { id: adminId },
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
seenAt: null,
|
seenAt: null,
|
||||||
};
|
};
|
||||||
return this.em.count(Notification, where);
|
return this.em.count(Notification, where);
|
||||||
}
|
}
|
||||||
|
|
||||||
readAllNotifsAsUser(userId: string, restaurantId: string): Promise<number> {
|
readAllNotifsAsUser(userId: string, : string): Promise < number > {
|
||||||
const where: FilterQuery<Notification> = {
|
const where: FilterQuery<Notification> = {
|
||||||
user: { id: userId },
|
user: { id: userId },
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
seenAt: null,
|
seenAt: null,
|
||||||
};
|
};
|
||||||
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
||||||
}
|
}
|
||||||
|
|
||||||
readAllNotifsAsAdmin(adminId: string, restaurantId: string): Promise<number> {
|
readAllNotifsAsAdmin(adminId: string, : string): Promise < number > {
|
||||||
const where: FilterQuery<Notification> = {
|
const where: FilterQuery<Notification> = {
|
||||||
admin: { id: adminId },
|
admin: { id: adminId },
|
||||||
restaurant: { id: restaurantId },
|
restaurant: { id: },
|
||||||
seenAt: null,
|
seenAt: null,
|
||||||
};
|
};
|
||||||
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSmsCountByRestaurant(
|
async getSmsCountByRestaurant(
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 10,
|
limit: number = 10,
|
||||||
): Promise<PaginatedResult<{ restaurantId: string; restaurantName: string; smsCount: number }>> {
|
): Promise < PaginatedResult < { : string; restaurantName: string; smsCount: number } >> {
|
||||||
return this.smsLogRepository.getSmsCountByRestaurant(page, limit);
|
return this.smsLogRepository.getSmsCountByRestaurant(page, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
|
async getSmsCountBy(: string): Promise < number > {
|
||||||
return this.smsLogRepository.getSmsCountByRestaurantId(restaurantId);
|
return this.smsLogRepository.getSmsCountBy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiHeader, ApiBody } fr
|
|||||||
import { OrdersService } from '../providers/orders.service';
|
import { OrdersService } from '../providers/orders.service';
|
||||||
import { AuthGuard } from '../../auth/guards/auth.guard';
|
import { AuthGuard } from '../../auth/guards/auth.guard';
|
||||||
import { UserId } from '../../../common/decorators/user-id.decorator';
|
import { UserId } from '../../../common/decorators/user-id.decorator';
|
||||||
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
|
||||||
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
||||||
import { FindOrdersDto } from '../dto/find-orders.dto';
|
import { FindOrdersDto } from '../dto/find-orders.dto';
|
||||||
import { OrderStatus } from '../interface/order.interface';
|
import { OrderStatus } from '../interface/order.interface';
|
||||||
import { API_HEADER_SLUG } from 'src/common/constants/index';
|
|
||||||
import { UpdateOrderStatusDto } from '../dto/update-order-status.dto';
|
import { UpdateOrderStatusDto } from '../dto/update-order-status.dto';
|
||||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
import { Permission } from 'src/common/enums/permission.enum';
|
import { Permission } from 'src/common/enums/permission.enum';
|
||||||
@@ -18,103 +16,103 @@ import { Permission } from 'src/common/enums/permission.enum';
|
|||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
constructor(private readonly ordersService: OrdersService) { }
|
constructor(private readonly ordersService: OrdersService) { }
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @UseGuards(AuthGuard)
|
||||||
@Post('public/checkout')
|
// @Post('public/checkout')
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Checkout : create order and payment record' })
|
// @ApiOperation({ summary: 'Checkout : create order and payment record' })
|
||||||
checkout(@UserId() userId: string, @RestId() restaurantId: string) {
|
// checkout(@UserId() userId: string,) {
|
||||||
return this.ordersService.checkout(userId, restaurantId);
|
// return this.ordersService.checkout(userId);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @UseGuards(AuthGuard)
|
||||||
@Get('public/orders')
|
// @Get('public/orders')
|
||||||
|
|
||||||
@ApiOperation({ summary: 'Get all orders with pagination and filters' })
|
// @ApiOperation({ summary: 'Get all orders with pagination and filters' })
|
||||||
findAll(, @Query() dto: FindOrdersDto, @UserId() userId: string) {
|
// findAll(@Query() dto: FindOrdersDto, @UserId() userId: string) {
|
||||||
return this.ordersService.findAllForUser(restId, dto, userId);
|
// return this.ordersService.findAllForUser(dto, userId);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @UseGuards(AuthGuard)
|
||||||
@ApiOperation({ summary: 'Get an order By id for User' })
|
// @ApiOperation({ summary: 'Get an order By id for User' })
|
||||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
// @ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
|
|
||||||
@Get('public/orders/:orderId')
|
// @Get('public/orders/:orderId')
|
||||||
findOne(@Param('orderId') orderId: string,) {
|
// findOne(@Param('orderId') orderId: string,) {
|
||||||
return this.ordersService.findOne(orderId, restId);
|
// return this.ordersService.findOne(orderId,);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @UseGuards(AuthGuard)
|
||||||
@Patch('public/orders/:id/:status')
|
// @Patch('public/orders/:id/:status')
|
||||||
@ApiParam({
|
// @ApiParam({
|
||||||
name: 'status',
|
// name: 'status',
|
||||||
description: 'Order status',
|
// description: 'Order status',
|
||||||
enum: OrderStatus,
|
// enum: OrderStatus,
|
||||||
})
|
// })
|
||||||
|
|
||||||
@ApiBody({ type: UpdateOrderStatusDto })
|
// @ApiBody({ type: UpdateOrderStatusDto })
|
||||||
@ApiOperation({ summary: 'Update status of an order By User' })
|
// @ApiOperation({ summary: 'Update status of an order By User' })
|
||||||
@ApiParam({ name: 'id', description: 'Order ID' })
|
// @ApiParam({ name: 'id', description: 'Order ID' })
|
||||||
cancelOrder(
|
// cancelOrder(
|
||||||
@Body() dto: UpdateOrderStatusDto,
|
// @Body() dto: UpdateOrderStatusDto,
|
||||||
@Param('id') orderId: string,
|
// @Param('id') orderId: string,
|
||||||
@Param('status') status: OrderStatus,
|
// @Param('status') status: OrderStatus,
|
||||||
,
|
// ,
|
||||||
) {
|
// ) {
|
||||||
return this.ordersService.changeOrderStatus(orderId, restId, status, 'user', dto?.desc);
|
// return this.ordersService.changeOrderStatus(orderId, , status, 'user', dto?.desc);
|
||||||
}
|
// }
|
||||||
|
|
||||||
/******************** Admin Routes **********************/
|
// /******************** Admin Routes **********************/
|
||||||
@UseGuards(AdminAuthGuard)
|
// @UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.MANAGE_ORDERS)
|
// @Permissions(Permission.MANAGE_ORDERS)
|
||||||
@Get('admin/orders')
|
// @Get('admin/orders')
|
||||||
@ApiOperation({ summary: 'Get all orders with pagination and filters' })
|
// @ApiOperation({ summary: 'Get all orders with pagination and filters' })
|
||||||
findAllAdmin(, @Query() dto: FindOrdersDto) {
|
// findAllAdmin(, @Query() dto: FindOrdersDto) {
|
||||||
return this.ordersService.findAllForAdmin(restId, dto);
|
// return this.ordersService.findAllForAdmin(, dto);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
// @UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.MANAGE_ORDERS)
|
// @Permissions(Permission.MANAGE_ORDERS)
|
||||||
@ApiOperation({ summary: 'Get an order By id for User' })
|
// @ApiOperation({ summary: 'Get an order By id for User' })
|
||||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
// @ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
@Get('admin/orders/:orderId')
|
// @Get('admin/orders/:orderId')
|
||||||
findOneAsAdmin(@Param('orderId') orderId: string,) {
|
// findOneAsAdmin(@Param('orderId') orderId: string,) {
|
||||||
return this.ordersService.findOne(orderId, restId);
|
// return this.ordersService.findOne(orderId,);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
// @UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.MANAGE_ORDERS)
|
// @Permissions(Permission.MANAGE_ORDERS)
|
||||||
@Patch('admin/orders/:orderId/:status')
|
// @Patch('admin/orders/:orderId/:status')
|
||||||
@ApiOperation({ summary: 'Update an order status' })
|
// @ApiOperation({ summary: 'Update an order status' })
|
||||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
// @ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
@ApiBody({ type: UpdateOrderStatusDto })
|
// @ApiBody({ type: UpdateOrderStatusDto })
|
||||||
@ApiParam({
|
// @ApiParam({
|
||||||
name: 'status',
|
// name: 'status',
|
||||||
description: 'Order status',
|
// description: 'Order status',
|
||||||
enum: OrderStatus,
|
// enum: OrderStatus,
|
||||||
})
|
// })
|
||||||
updateStatus(
|
// updateStatus(
|
||||||
@Param('orderId') orderId: string,
|
// @Param('orderId') orderId: string,
|
||||||
@Body() dto: UpdateOrderStatusDto,
|
// @Body() dto: UpdateOrderStatusDto,
|
||||||
@Param('status') status: OrderStatus,
|
// @Param('status') status: OrderStatus,
|
||||||
,
|
// ,
|
||||||
) {
|
// ) {
|
||||||
return this.ordersService.changeOrderStatus(orderId, restId, status, 'admin', dto?.desc);
|
// return this.ordersService.changeOrderStatus(orderId, , status, 'admin', dto?.desc);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
// @UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.VIEW_REPORTS)
|
// @Permissions(Permission.VIEW_REPORTS)
|
||||||
@ApiOperation({ summary: 'Get Stats for report page' })
|
// @ApiOperation({ summary: 'Get Stats for report page' })
|
||||||
@Get('admin/orders/stats')
|
// @Get('admin/orders/stats')
|
||||||
findStats() {
|
// findStats() {
|
||||||
return this.ordersService.getStats(restId);
|
// return this.ordersService.getStats();
|
||||||
}
|
// }
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
// @UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.VIEW_REPORTS)
|
// @Permissions(Permission.VIEW_REPORTS)
|
||||||
@ApiOperation({ summary: 'Get product sales pie chart data for last month' })
|
// @ApiOperation({ summary: 'Get product sales pie chart data for last month' })
|
||||||
|
|
||||||
@Get('admin/orders/product-sales-pie-chart')
|
// @Get('admin/orders/product-sales-pie-chart')
|
||||||
getproductSalesPieChart() {
|
// getproductSalesPieChart() {
|
||||||
return this.ordersService.getproductSalesPieChart(restId);
|
// return this.ordersService.getproductSalesPieChart();
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,10 +144,10 @@ export class OrdersCrone {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousStatus = reloadedOrder.status;
|
const previousStatus = reloadedOrder.status;
|
||||||
const restaurantId =
|
const =
|
||||||
typeof reloadedOrder.restaurant === 'string'
|
typeof reloadedOrder.restaurant === 'string'
|
||||||
? reloadedOrder.restaurant
|
? reloadedOrder.restaurant
|
||||||
: reloadedOrder.restaurant.id;
|
: reloadedOrder.restaurant.id;
|
||||||
|
|
||||||
// Update order status and history
|
// Update order status and history
|
||||||
reloadedOrder.status = OrderStatus.COMPLETED;
|
reloadedOrder.status = OrderStatus.COMPLETED;
|
||||||
@@ -167,7 +167,7 @@ export class OrdersCrone {
|
|||||||
reloadedOrder.id,
|
reloadedOrder.id,
|
||||||
reloadedOrder.user?.id || '',
|
reloadedOrder.user?.id || '',
|
||||||
String(reloadedOrder.orderNumber) || '',
|
String(reloadedOrder.orderNumber) || '',
|
||||||
restaurantId,
|
,
|
||||||
previousStatus,
|
previousStatus,
|
||||||
OrderStatus.COMPLETED,
|
OrderStatus.COMPLETED,
|
||||||
'admin',
|
'admin',
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { OrderStatus, StatusTransitionRef } from '../interface/order.interf
|
|||||||
export class OrderCreatedEvent {
|
export class OrderCreatedEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly orderId: string,
|
public readonly orderId: string,
|
||||||
public readonly restaurantId: string,
|
public readonly: string,
|
||||||
public readonly orderNumber: string,
|
public readonly orderNumber: string,
|
||||||
public readonly total: number,
|
public readonly total: number,
|
||||||
) {}
|
) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OrderStatusChangedEvent {
|
export class OrderStatusChangedEvent {
|
||||||
@@ -14,9 +14,9 @@ export class OrderStatusChangedEvent {
|
|||||||
public readonly orderId: string,
|
public readonly orderId: string,
|
||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly orderNumber: string,
|
public readonly orderNumber: string,
|
||||||
public readonly restaurantId: string,
|
public readonly: string,
|
||||||
public readonly previousStatus: OrderStatus,
|
public readonly previousStatus: OrderStatus,
|
||||||
public readonly newStatus: OrderStatus,
|
public readonly newStatus: OrderStatus,
|
||||||
public readonly changedBy: StatusTransitionRef,
|
public readonly changedBy: StatusTransitionRef,
|
||||||
) {}
|
) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class OrderListeners {
|
|||||||
async handleOrderCreated(event: OrderCreatedEvent) {
|
async handleOrderCreated(event: OrderCreatedEvent) {
|
||||||
try {
|
try {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Order created event received: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
`Order created event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const order = await this.OrderRepository.findOne(event.orderId);
|
const order = await this.OrderRepository.findOne(event.orderId);
|
||||||
@@ -58,13 +58,13 @@ export class OrderListeners {
|
|||||||
|
|
||||||
|
|
||||||
// get admnin os restuaraant that have order permissuins
|
// get admnin os restuaraant that have order permissuins
|
||||||
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.MANAGE_ORDERS);
|
const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
|
||||||
const recipients = admins.map(admin => ({
|
const recipients = admins.map(admin => ({
|
||||||
adminId: admin.id,
|
adminId: admin.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await this.notificationService.sendNotification({
|
await this.notificationService.sendNotification({
|
||||||
restaurantId: event.restaurantId,
|
: event.,
|
||||||
message: {
|
message: {
|
||||||
title: NotifTitleEnum.ORDER_CREATED,
|
title: NotifTitleEnum.ORDER_CREATED,
|
||||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||||
@@ -92,7 +92,7 @@ export class OrderListeners {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to send notification for order created event: ${event.restaurantId}`,
|
`Failed to send notification for order created event: ${event.}`,
|
||||||
error instanceof Error ? error.stack : String(error),
|
error instanceof Error ? error.stack : String(error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ export class OrderListeners {
|
|||||||
async handleOrderStatusChanged(event: OrderStatusChangedEvent) {
|
async handleOrderStatusChanged(event: OrderStatusChangedEvent) {
|
||||||
try {
|
try {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Order status changed event received: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
`Order status changed event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||||
);
|
);
|
||||||
//TODO : REFACTOR to use queue or other way to handle this
|
//TODO : REFACTOR to use queue or other way to handle this
|
||||||
const recipients = [
|
const recipients = [
|
||||||
@@ -114,21 +114,21 @@ export class OrderListeners {
|
|||||||
|
|
||||||
if (!event?.userId) {
|
if (!event?.userId) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`User not found for order: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
`User not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// const restaurant = await this.RestaurantRepository.findOne(event.restaurantId);
|
// const restaurant = await this.RestaurantRepository.findOne(event.);
|
||||||
// if (!restaurant) {
|
// if (!restaurant) {
|
||||||
// this.logger.log(
|
// this.logger.log(
|
||||||
// `Restaurant not found for order: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
// `Restaurant not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||||
// );
|
// );
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
// const score = restaurant.score;
|
// const score = restaurant.score;
|
||||||
// if (!score) {
|
// if (!score) {
|
||||||
// this.logger.log(
|
// this.logger.log(
|
||||||
// `Score not found for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
// `Score not found for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||||
// );
|
// );
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
@@ -137,18 +137,18 @@ export class OrderListeners {
|
|||||||
// const order = await this.OrderRepository.findOne(event.orderId);
|
// const order = await this.OrderRepository.findOne(event.orderId);
|
||||||
// if (!order) {
|
// if (!order) {
|
||||||
// this.logger.log(
|
// this.logger.log(
|
||||||
// `Order not found for order: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
// `Order not found for order: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||||
// );
|
// );
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
// this.userService.createWalletTransaction(event.userId, event.restaurantId, {
|
// this.userService.createWalletTransaction(event.userId, event., {
|
||||||
// amount: order.subTotal,
|
// amount: order.subTotal,
|
||||||
// type: WalletTransactionType.CREDIT,
|
// type: WalletTransactionType.CREDIT,
|
||||||
// reason: WalletTransactionReason.ORDER_COMPLETED_DEPOSIT,
|
// reason: WalletTransactionReason.ORDER_COMPLETED_DEPOSIT,
|
||||||
// });
|
// });
|
||||||
|
|
||||||
await this.notificationService.sendNotification({
|
await this.notificationService.sendNotification({
|
||||||
restaurantId: event.restaurantId,
|
: event.,
|
||||||
message: {
|
message: {
|
||||||
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||||
content: `لطفابرای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
|
content: `لطفابرای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
|
||||||
@@ -175,7 +175,7 @@ export class OrderListeners {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.notificationService.sendNotification({
|
await this.notificationService.sendNotification({
|
||||||
restaurantId: event.restaurantId,
|
: event.,
|
||||||
message: {
|
message: {
|
||||||
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||||
content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
|
content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
|
||||||
@@ -204,7 +204,7 @@ export class OrderListeners {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to send notification for order status changed event: ${event.restaurantId}`,
|
`Failed to send notification for order status changed event: ${event.}`,
|
||||||
error instanceof Error ? error.stack : String(error),
|
error instanceof Error ? error.stack : String(error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,599 +3,36 @@ import { EntityManager } from '@mikro-orm/postgresql';
|
|||||||
import { Order } from '../entities/order.entity';
|
import { Order } from '../entities/order.entity';
|
||||||
import { OrderItem } from '../entities/order-item.entity';
|
import { OrderItem } from '../entities/order-item.entity';
|
||||||
import { User } from '../../user/entities/user.entity';
|
import { User } from '../../user/entities/user.entity';
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
|
||||||
import { product } from '../../products/entities/product.entity';
|
|
||||||
import { CartService } from '../../cart/providers/cart.service';
|
|
||||||
import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
|
||||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payment/interface/payment';
|
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payment/interface/payment';
|
||||||
import { Cart } from '../../cart/interfaces/cart.interface';
|
|
||||||
import { PaymentMethod } from '../../payment/entities/payment-method.entity';
|
import { PaymentMethod } from '../../payment/entities/payment-method.entity';
|
||||||
import { PaymentsService } from '../../payment/services/payments.service';
|
import { PaymentsService } from '../../payment/services/payments.service';
|
||||||
import { DeliveryMethodEnum } from '../../delivery/interface/delivery';
|
|
||||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
|
||||||
import { OrderRepository } from '../repositories/order.repository';
|
import { OrderRepository } from '../repositories/order.repository';
|
||||||
import { FindOrdersDto } from '../dto/find-orders.dto';
|
import { FindOrdersDto } from '../dto/find-orders.dto';
|
||||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
import { Payment } from 'src/modules/payment/entities/payment.entity';
|
import { Payment } from 'src/modules/payment/entities/payment.entity';
|
||||||
import { InventoryService } from 'src/modules/inventory/inventory.service';
|
|
||||||
import { BulkReserveproductDto } from 'src/modules/inventory/dto/bulk-reserve-product.dto';
|
|
||||||
import { StatusTransitionRef } from '../interface/order.interface';
|
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
|
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
|
||||||
import { OrderMessage } from 'src/common/enums/message.enum';
|
import { OrderMessage } from 'src/common/enums/message.enum';
|
||||||
type OrderItemData = { product: product; quantity: number; unitPrice: number; discount: number };
|
|
||||||
|
|
||||||
type ValidatedCartForOrder = {
|
|
||||||
user: User;
|
|
||||||
restaurant: Restaurant;
|
|
||||||
delivery: Delivery;
|
|
||||||
userAddress: OrderUserAddress | null;
|
|
||||||
carAddress: OrderCarAddress | null;
|
|
||||||
paymentMethod: PaymentMethod;
|
|
||||||
orderItemsData: OrderItemData[];
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrdersService {
|
export class OrdersService {
|
||||||
private readonly logger = new Logger(OrdersService.name);
|
private readonly logger = new Logger(OrdersService.name);
|
||||||
|
|
||||||
private static readonly STATUS_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
|
|
||||||
[OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.CANCELED, OrderStatus.PREPARING],
|
|
||||||
[OrderStatus.PAID]: [OrderStatus.PREPARING, OrderStatus.CANCELED],
|
|
||||||
[OrderStatus.PREPARING]: [OrderStatus.DELIVERED_TO_RECEPTIONIST, OrderStatus.DELIVERED_TO_WAITER, OrderStatus.SHIPPED, OrderStatus.CANCELED],
|
|
||||||
[OrderStatus.DELIVERED_TO_WAITER]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
|
|
||||||
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
|
|
||||||
[OrderStatus.SHIPPED]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
|
|
||||||
[OrderStatus.COMPLETED]: [OrderStatus.CANCELED],
|
|
||||||
[OrderStatus.CANCELED]: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
private readonly cartService: CartService,
|
|
||||||
private readonly orderRepository: OrderRepository,
|
private readonly orderRepository: OrderRepository,
|
||||||
private readonly paymentsService: PaymentsService,
|
private readonly paymentsService: PaymentsService,
|
||||||
private readonly inventoryService: InventoryService,
|
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async checkout(userId: string, restaurantId: string) {
|
|
||||||
const cart = await this.cartService.findOneOrFail(userId, restaurantId);
|
|
||||||
const validated = await this.validateCartForOrder(userId, restaurantId, cart);
|
|
||||||
|
|
||||||
const order = await this.em.transactional(async em => {
|
|
||||||
const order = em.create(Order, {
|
|
||||||
user: validated.user,
|
|
||||||
restaurant: validated.restaurant,
|
|
||||||
deliveryMethod: validated.delivery,
|
|
||||||
userAddress: validated.userAddress,
|
|
||||||
carAddress: validated.carAddress,
|
|
||||||
paymentMethod: validated.paymentMethod,
|
|
||||||
couponDiscount: cart.couponDiscount || 0,
|
|
||||||
couponDetail: cart.coupon,
|
|
||||||
itemsDiscount: cart.itemsDiscount || 0,
|
|
||||||
totalDiscount: cart.totalDiscount || 0,
|
|
||||||
subTotal: cart.subTotal || 0,
|
|
||||||
tax: cart.tax || 0,
|
|
||||||
deliveryFee: cart.deliveryFee || 0,
|
|
||||||
total: cart.total || 0,
|
|
||||||
totalItems: cart.totalItems || 0,
|
|
||||||
description: cart.description,
|
|
||||||
tableNumber: cart.tableNumber,
|
|
||||||
status: OrderStatus.PENDING_PAYMENT,
|
|
||||||
history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }],
|
|
||||||
});
|
|
||||||
|
|
||||||
em.persist(order);
|
|
||||||
|
|
||||||
for (const itemData of validated.orderItemsData) {
|
|
||||||
const { product, quantity, unitPrice, discount } = itemData;
|
|
||||||
|
|
||||||
this.assertproductHasSufficientStock(product, quantity);
|
|
||||||
|
|
||||||
const totalPrice = (unitPrice - discount) * quantity;
|
|
||||||
|
|
||||||
const orderItem = em.create(OrderItem, {
|
|
||||||
order,
|
|
||||||
product,
|
|
||||||
quantity,
|
|
||||||
unitPrice,
|
|
||||||
discount,
|
|
||||||
totalPrice,
|
|
||||||
});
|
|
||||||
|
|
||||||
em.persist(orderItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payment = em.create(Payment, {
|
|
||||||
order,
|
|
||||||
amount: order.total,
|
|
||||||
status: PaymentStatusEnum.Pending,
|
|
||||||
method: order.paymentMethod.method,
|
|
||||||
gateway: order.paymentMethod.gateway ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
em.persist(payment);
|
|
||||||
// reserve stock based on payment method.
|
|
||||||
const bulkReserveproductDto: BulkReserveproductDto = {
|
|
||||||
items: validated.orderItemsData.map(item => ({
|
|
||||||
productId: item.product.id,
|
|
||||||
quantity: item.quantity,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
await this.inventoryService.deductFromInventory(em, bulkReserveproductDto);
|
|
||||||
await em.flush();
|
|
||||||
this.logger.debug(`Order ${order.id} created for user ${userId} (restaurant ${restaurantId})`);
|
|
||||||
return order;
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.cartService.clearCart(userId, restaurantId);
|
|
||||||
|
|
||||||
const { paymentUrl } = await this.paymentsService.payOrder(order.id);
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
OrderCreatedEvent.name,
|
|
||||||
new OrderCreatedEvent(order.id, restaurantId, String(order?.orderNumber) || '', order.total),
|
|
||||||
);
|
|
||||||
|
|
||||||
return { paymentUrl, order };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates cart and prepares all required data for order creation
|
|
||||||
*/
|
|
||||||
private async validateCartForOrder(userId: string, restaurantId: string, cart: Cart): Promise<ValidatedCartForOrder> {
|
|
||||||
this.assertCartHasItems(cart);
|
|
||||||
this.assertCartHasDeliveryMethod(cart);
|
|
||||||
this.assertCartHasPaymentMethod(cart);
|
|
||||||
|
|
||||||
const [user, restaurant, delivery] = await Promise.all([
|
|
||||||
this.getUserOrFail(userId),
|
|
||||||
this.getRestaurantOrFail(restaurantId),
|
|
||||||
this.getDeliveryOrFail(cart.deliveryMethodId!),
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.assertMeetsMinOrderForDelivery(cart, delivery);
|
|
||||||
this.assertDeliveryMethodRequirements(cart, delivery);
|
|
||||||
|
|
||||||
const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId);
|
|
||||||
this.assertPaymentMethodEnabled(paymentMethod);
|
|
||||||
|
|
||||||
const orderItemsData = await this.buildOrderItemsData(cart, restaurantId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
restaurant,
|
|
||||||
delivery,
|
|
||||||
paymentMethod,
|
|
||||||
userAddress: delivery.method === DeliveryMethodEnum.DeliveryCourier ? (cart?.userAddress ?? null) : null,
|
|
||||||
carAddress: delivery.method === DeliveryMethodEnum.DeliveryCar ? (cart?.carAddress ?? null) : null,
|
|
||||||
orderItemsData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAllForUser(restId: string, dto: FindOrdersDto, userId: string): Promise<PaginatedResult<Order>> {
|
|
||||||
const result = await this.orderRepository.findAllPaginated(restId, {
|
|
||||||
page: dto.page,
|
|
||||||
limit: dto.limit,
|
|
||||||
statuses: dto.statuses,
|
|
||||||
paymentStatus: dto.paymentStatus,
|
|
||||||
search: dto.search,
|
|
||||||
startDate: dto.startDate,
|
|
||||||
endDate: dto.endDate,
|
|
||||||
orderBy: dto.orderBy,
|
|
||||||
order: dto.order,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAllForAdmin(restId: string, dto: FindOrdersDto): Promise<PaginatedResult<Order>> {
|
|
||||||
const result = await this.orderRepository.findAllPaginated(restId, {
|
|
||||||
page: dto.page,
|
|
||||||
limit: dto.limit,
|
|
||||||
statuses: dto.statuses,
|
|
||||||
paymentStatus: dto.paymentStatus,
|
|
||||||
search: dto.search,
|
|
||||||
startDate: dto.startDate,
|
|
||||||
endDate: dto.endDate,
|
|
||||||
orderBy: dto.orderBy,
|
|
||||||
order: dto.order,
|
|
||||||
excludeOnlinePendingPayment: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOne(id: string, restId: string): Promise<Order> {
|
|
||||||
const order = await this.orderRepository.findOne(
|
|
||||||
{ id, restaurant: { id: restId } },
|
|
||||||
{
|
|
||||||
populate: [
|
|
||||||
'user',
|
|
||||||
'restaurant',
|
|
||||||
'deliveryMethod',
|
|
||||||
'userAddress',
|
|
||||||
'carAddress',
|
|
||||||
'paymentMethod',
|
|
||||||
'payments',
|
|
||||||
'items',
|
|
||||||
'items.product',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!order) {
|
|
||||||
throw new NotFoundException(OrderMessage.NOT_FOUND);
|
|
||||||
}
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async changeOrderStatus(
|
|
||||||
orderId: string,
|
|
||||||
restId: string,
|
|
||||||
toStatus: OrderStatus,
|
|
||||||
ref: StatusTransitionRef,
|
|
||||||
desc?: string,
|
|
||||||
): Promise<Order> {
|
|
||||||
const order = await this.getOrderOrFail(orderId, restId);
|
|
||||||
|
|
||||||
// Store previous status before changing it
|
|
||||||
const previousStatus = order.status;
|
|
||||||
|
|
||||||
this.assertStatusTransitionAllowed(order, toStatus, ref);
|
|
||||||
|
|
||||||
order.status = toStatus;
|
|
||||||
order.history.push({ status: toStatus, changedAt: new Date(), desc: desc || null });
|
|
||||||
await this.em.persistAndFlush(order);
|
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
OrderStatusChangedEvent.name,
|
|
||||||
new OrderStatusChangedEvent(
|
|
||||||
orderId,
|
|
||||||
order.user?.id || '',
|
|
||||||
String(order?.orderNumber) || '',
|
|
||||||
restId,
|
|
||||||
previousStatus,
|
|
||||||
toStatus,
|
|
||||||
ref,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
/* Helpers */
|
|
||||||
private assertStatusTransitionAllowed(order: Order, to: OrderStatus, ref: StatusTransitionRef) {
|
|
||||||
const paymentMethod = order.paymentMethod?.method;
|
|
||||||
if (!paymentMethod) {
|
|
||||||
throw new BadRequestException(OrderMessage.PAYMENT_METHOD_MISSING);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.canTransition(order.status, to, paymentMethod, ref, order.deliveryMethod.method)) {
|
|
||||||
throw new BadRequestException(OrderMessage.INVALID_STATUS_TRANSITION);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum, ref: 'user' | 'admin', deliveryMethod: DeliveryMethodEnum) {
|
|
||||||
if (!OrdersService.STATUS_TRANSITIONS[from]?.includes(to)) return false;
|
|
||||||
|
|
||||||
if (to === OrderStatus.CANCELED) {
|
|
||||||
// only allow orders with status of PENDING_PAYMENT and PAID are allowed to be canceled by user
|
|
||||||
if (ref === 'user' && ![OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(from)) {
|
|
||||||
return false;
|
|
||||||
} else if (ref === 'admin') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// only allow orders with status of PENDING_PAYMENT and payment
|
|
||||||
// method of cash are allowed to move to CONFIRMED directly
|
|
||||||
if (
|
|
||||||
from == OrderStatus.PENDING_PAYMENT &&
|
|
||||||
to == OrderStatus.PREPARING &&
|
|
||||||
paymentMethod !== PaymentMethodEnum.Cash
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only allow orders with status of PREPARING to be shipped if the delivery method is DeliveryCourier
|
|
||||||
*/
|
|
||||||
if (
|
|
||||||
from == OrderStatus.PREPARING &&
|
|
||||||
to == OrderStatus.SHIPPED &&
|
|
||||||
deliveryMethod !== DeliveryMethodEnum.DeliveryCourier
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
from == OrderStatus.PREPARING &&
|
|
||||||
to == OrderStatus.DELIVERED_TO_WAITER &&
|
|
||||||
deliveryMethod !== DeliveryMethodEnum.DineIn &&
|
|
||||||
deliveryMethod !== DeliveryMethodEnum.DeliveryCar
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
from == OrderStatus.PREPARING &&
|
|
||||||
to == OrderStatus.DELIVERED_TO_RECEPTIONIST &&
|
|
||||||
deliveryMethod !== DeliveryMethodEnum.CustomerPickup
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paymentMethod === PaymentMethodEnum.Online) {
|
|
||||||
if (to === OrderStatus.PREPARING && from !== OrderStatus.PAID) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getOrderOrFail(orderId: string, restId: string): Promise<Order> {
|
|
||||||
const order = await this.em.findOne(
|
|
||||||
Order,
|
|
||||||
{ id: orderId, restaurant: { id: restId } },
|
|
||||||
{ populate: ['paymentMethod', 'deliveryMethod', 'user'] },
|
|
||||||
);
|
|
||||||
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertCartHasItems(cart: Cart) {
|
|
||||||
if (!cart.items || cart.items.length === 0) {
|
|
||||||
throw new BadRequestException(OrderMessage.CART_EMPTY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertCartHasDeliveryMethod(cart: Cart) {
|
|
||||||
if (!cart.deliveryMethodId) {
|
|
||||||
throw new NotFoundException(OrderMessage.DELIVERY_METHOD_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertCartHasPaymentMethod(cart: Cart) {
|
|
||||||
if (!cart.paymentMethodId) {
|
|
||||||
throw new BadRequestException(OrderMessage.PAYMENT_METHOD_REQUIRED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getUserOrFail(userId: string): Promise<User> {
|
|
||||||
const user = await this.em.findOne(User, { id: userId });
|
|
||||||
if (!user) throw new NotFoundException(OrderMessage.USER_NOT_FOUND);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getRestaurantOrFail(restaurantId: string): Promise<Restaurant> {
|
|
||||||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
|
||||||
if (!restaurant) throw new NotFoundException(OrderMessage.RESTAURANT_NOT_FOUND);
|
|
||||||
return restaurant;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getDeliveryOrFail(deliveryId: string): Promise<Delivery> {
|
|
||||||
const delivery = await this.em.findOne(Delivery, { id: deliveryId });
|
|
||||||
if (!delivery) throw new NotFoundException(OrderMessage.DELIVERY_NOT_FOUND);
|
|
||||||
return delivery;
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertMeetsMinOrderForDelivery(cart: Cart, delivery: Delivery) {
|
|
||||||
const minOrderPrice = Number(delivery.minOrderPrice) || 0;
|
|
||||||
if (minOrderPrice > 0 && cart.total < minOrderPrice) {
|
|
||||||
throw new BadRequestException(OrderMessage.MIN_ORDER_AMOUNT_NOT_MET);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertDeliveryMethodRequirements(cart: Cart, delivery: Delivery) {
|
|
||||||
if (delivery.method === DeliveryMethodEnum.DineIn) {
|
|
||||||
if (!cart.tableNumber || cart.tableNumber.trim() === '') {
|
|
||||||
throw new BadRequestException(OrderMessage.TABLE_NUMBER_REQUIRED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !cart.userAddress) {
|
|
||||||
throw new BadRequestException(OrderMessage.ADDRESS_REQUIRED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delivery.method === DeliveryMethodEnum.DeliveryCar && !cart.carAddress) {
|
|
||||||
throw new BadRequestException(OrderMessage.CAR_ADDRESS_REQUIRED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getPaymentMethodOrFail(paymentMethodId: string, restaurantId: string): Promise<PaymentMethod> {
|
|
||||||
const paymentMethod = await this.em.findOne(
|
|
||||||
PaymentMethod,
|
|
||||||
{
|
|
||||||
id: paymentMethodId,
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
},
|
|
||||||
{ populate: ['restaurant'] },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!paymentMethod) {
|
|
||||||
throw new NotFoundException(`Payment method with ID ${paymentMethodId} not found for restaurant ${restaurantId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return paymentMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertPaymentMethodEnabled(paymentMethod: PaymentMethod) {
|
|
||||||
if (!paymentMethod.enabled) {
|
|
||||||
throw new BadRequestException(OrderMessage.PAYMENT_METHOD_NOT_ENABLED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async buildOrderItemsData(cart: Cart, restaurantId: string): Promise<OrderItemData[]> {
|
|
||||||
const orderItemsData: OrderItemData[] = [];
|
|
||||||
|
|
||||||
for (const cartItem of cart.items) {
|
|
||||||
const product = await this.em.findOne(product, { id: cartItem.productId }, { populate: ['restaurant', 'inventory'] });
|
|
||||||
if (!product) throw new NotFoundException(OrderMessage.product_NOT_FOUND);
|
|
||||||
|
|
||||||
if (product.restaurant.id !== restaurantId) {
|
|
||||||
throw new BadRequestException(OrderMessage.product_NOT_BELONGS_TO_RESTAURANT);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.assertproductHasSufficientStock(product, cartItem.quantity);
|
|
||||||
|
|
||||||
orderItemsData.push({
|
|
||||||
product,
|
|
||||||
quantity: cartItem.quantity,
|
|
||||||
unitPrice: product.price || 0,
|
|
||||||
discount: product.discount || 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return orderItemsData;
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertproductHasSufficientStock(product: product, quantity: number) {
|
|
||||||
const availableStock = product.inventory?.availableStock ?? 0;
|
|
||||||
if (availableStock < quantity) {
|
|
||||||
throw new BadRequestException(OrderMessage.product_NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async getStats(restId: string) {
|
|
||||||
return this.em.transactional(async em => {
|
|
||||||
// 1. Total orders count (excluding pending and canceled)
|
|
||||||
const totalOrders = await em.count(Order, {
|
|
||||||
restaurant: { id: restId },
|
|
||||||
status: {
|
|
||||||
$in: [
|
|
||||||
OrderStatus.PAID,
|
|
||||||
OrderStatus.PREPARING,
|
|
||||||
OrderStatus.DELIVERED_TO_WAITER,
|
|
||||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
|
||||||
OrderStatus.SHIPPED,
|
|
||||||
OrderStatus.COMPLETED,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Active products count
|
|
||||||
const activeproducts = await em.count(product, {
|
|
||||||
restaurant: { id: restId },
|
|
||||||
isActive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Total clients count (distinct users with orders for this restaurant)
|
|
||||||
const clientsResult = await em.execute(
|
|
||||||
`
|
|
||||||
SELECT COUNT(DISTINCT o.user_id) as count
|
|
||||||
FROM orders o
|
|
||||||
WHERE o.restaurant_id = ?
|
|
||||||
`,
|
|
||||||
[restId],
|
|
||||||
);
|
|
||||||
const totalClients = Number(clientsResult[0]?.count || 0);
|
|
||||||
|
|
||||||
// 4. Total revenue (sum of paid payments for orders of this restaurant)
|
|
||||||
const revenueResult = await em.execute(
|
|
||||||
`
|
|
||||||
SELECT COALESCE(SUM(p.amount), 0)::numeric as total
|
|
||||||
FROM payments p
|
|
||||||
INNER JOIN orders o ON p.order_id = o.id
|
|
||||||
WHERE o.restaurant_id = ? AND p.status = 'paid'
|
|
||||||
`,
|
|
||||||
[restId],
|
|
||||||
);
|
|
||||||
const totalRevenue = Number(revenueResult[0]?.total || 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalOrders,
|
|
||||||
activeproducts,
|
|
||||||
totalClients,
|
|
||||||
totalRevenue,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getproductSalesPieChart(restId: string) {
|
|
||||||
return this.em.transactional(async em => {
|
|
||||||
// Use last 30 days instead of just last month to be more inclusive
|
|
||||||
const now = new Date();
|
|
||||||
const endDate = new Date(now);
|
|
||||||
endDate.setHours(23, 59, 59, 999);
|
|
||||||
const startDate = new Date(now);
|
|
||||||
startDate.setDate(startDate.getDate() - 30);
|
|
||||||
startDate.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
// Calculate actual last month for the period display
|
|
||||||
const firstDayOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
||||||
firstDayOfLastMonth.setHours(0, 0, 0, 0);
|
|
||||||
const lastDayOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
|
||||||
|
|
||||||
this.logger.debug(
|
|
||||||
`product sales pie chart query params: restId=${restId}, startDate=${startDate.toISOString()}, endDate=${endDate.toISOString()}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Diagnostic query to check what orders exist
|
|
||||||
const diagnosticResult = await em.execute(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
o.id,
|
|
||||||
o.status,
|
|
||||||
o.created_at,
|
|
||||||
COUNT(oi.id) as item_count
|
|
||||||
FROM orders o
|
|
||||||
LEFT JOIN order_items oi ON oi.order_id = o.id
|
|
||||||
WHERE o.restaurant_id = ?
|
|
||||||
GROUP BY o.id, o.status, o.created_at
|
|
||||||
ORDER BY o.created_at DESC
|
|
||||||
LIMIT 10
|
|
||||||
`,
|
|
||||||
[restId],
|
|
||||||
);
|
|
||||||
this.logger.debug(`Diagnostic: Found ${diagnosticResult.length} recent orders for restaurant ${restId}`);
|
|
||||||
if (diagnosticResult.length > 0) {
|
|
||||||
this.logger.debug(`Sample order: status=${diagnosticResult[0].status}, created_at=${diagnosticResult[0].created_at}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query order items from orders in the date range
|
|
||||||
// Only include completed orders (excluding canceled and pending)
|
|
||||||
const result = await em.execute(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
f.id as product_id,
|
|
||||||
f.title as product_title,
|
|
||||||
COALESCE(SUM(oi.quantity), 0)::int as total_quantity,
|
|
||||||
COALESCE(SUM(oi.total_price), 0)::numeric as total_revenue
|
|
||||||
FROM order_items oi
|
|
||||||
INNER JOIN orders o ON oi.order_id = o.id
|
|
||||||
INNER JOIN products f ON oi.product_id = f.id
|
|
||||||
WHERE o.restaurant_id = ?
|
|
||||||
AND o.created_at >= ?
|
|
||||||
AND o.created_at <= ?
|
|
||||||
AND o.status NOT IN ('pendingPayment', 'canceled')
|
|
||||||
GROUP BY f.id, f.title
|
|
||||||
ORDER BY total_revenue DESC
|
|
||||||
`,
|
|
||||||
[restId, startDate, endDate],
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.debug(`product sales pie chart query returned ${result.length} rows`);
|
|
||||||
|
|
||||||
// Calculate total revenue for percentage calculation
|
|
||||||
const totalRevenue = result.reduce((sum: number, item: any) => {
|
|
||||||
return sum + Number(item.total_revenue || 0);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// Format data for pie chart and calculate percentages
|
|
||||||
const chartData = result.map((item: any) => ({
|
|
||||||
productId: item.product_id,
|
|
||||||
productTitle: item.product_title || 'Unknown',
|
|
||||||
quantity: Number(item.total_quantity || 0),
|
|
||||||
revenue: Number(item.total_revenue || 0),
|
|
||||||
percentage: totalRevenue > 0 ? Number(((Number(item.total_revenue || 0) / totalRevenue) * 100).toFixed(2)) : 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
period: {
|
|
||||||
startDate: firstDayOfLastMonth.toISOString(),
|
|
||||||
endDate: lastDayOfLastMonth.toISOString(),
|
|
||||||
},
|
|
||||||
totalRevenue,
|
|
||||||
data: chartData,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
|||||||
* Find orders with pagination and optional filters.
|
* Find orders with pagination and optional filters.
|
||||||
* Supports: statuses, paymentStatus, search (orderNumber), date range, ordering.
|
* Supports: statuses, paymentStatus, search (orderNumber), date range, ordering.
|
||||||
*/
|
*/
|
||||||
async findAllPaginated(restId: string, opts: FindOrdersOpts = {}): Promise<PaginatedResult<Order>> {
|
async findAllPaginated(: string, opts: FindOrdersOpts = {}): Promise<PaginatedResult<Order>> {
|
||||||
const {
|
const {
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
@@ -47,7 +47,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
|||||||
|
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const where: FilterQuery<Order> = { restaurant: { id: restId } };
|
const where: FilterQuery<Order> = { restaurant: { id: } };
|
||||||
|
|
||||||
// Filter by statuses
|
// Filter by statuses
|
||||||
if (statuses) {
|
if (statuses) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { CreatePaymentMethodDto } from '../dto/create-payment-method.dto';
|
|||||||
import { UpdatePaymentMethodDto } from '../dto/update-payment-method.dto';
|
import { UpdatePaymentMethodDto } from '../dto/update-payment-method.dto';
|
||||||
import { VerifyPaymentDto } from '../dto/verify-payment.dto';
|
import { VerifyPaymentDto } from '../dto/verify-payment.dto';
|
||||||
import { PaymentChartDto } from '../dto/payment-chart.dto';
|
import { PaymentChartDto } from '../dto/payment-chart.dto';
|
||||||
import { RestId, UserId } from 'src/common/decorators';
|
import { , UserId } from 'src/common/decorators';
|
||||||
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
||||||
import { API_HEADER_SLUG } from 'src/common/constants';
|
import { API_HEADER_SLUG } from 'src/common/constants';
|
||||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
@@ -36,7 +36,7 @@ export class PaymentsController {
|
|||||||
|
|
||||||
@ApiNotFoundResponse({ description: 'Restaurant payment methods not found' })
|
@ApiNotFoundResponse({ description: 'Restaurant payment methods not found' })
|
||||||
getTheRestaurantPaymentMethods() {
|
getTheRestaurantPaymentMethods() {
|
||||||
return this.paymentMethodService.findByRestaurant(restId);
|
return this.paymentMethodService.findByRestaurant();
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -45,7 +45,7 @@ export class PaymentsController {
|
|||||||
@ApiOperation({ summary: 'Get all the restaurant payments for user' })
|
@ApiOperation({ summary: 'Get all the restaurant payments for user' })
|
||||||
|
|
||||||
findAllByRestaurant(@UserId() userId: string,) {
|
findAllByRestaurant(@UserId() userId: string,) {
|
||||||
return this.paymentsService.findAllPaymentsByRestaurantId(restId, userId);
|
return this.paymentsService.findAllPaymentsBy(, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -74,7 +74,7 @@ export class PaymentsController {
|
|||||||
@Get('admin/payments/methods')
|
@Get('admin/payments/methods')
|
||||||
@ApiOperation({ summary: 'Get restaurant all payment methods' })
|
@ApiOperation({ summary: 'Get restaurant all payment methods' })
|
||||||
findByRestaurant() {
|
findByRestaurant() {
|
||||||
return this.paymentMethodService.findByRestaurant(restId);
|
return this.paymentMethodService.findByRestaurant();
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@@ -84,7 +84,7 @@ export class PaymentsController {
|
|||||||
@ApiOperation({ summary: 'Create a new restaurant payment method' })
|
@ApiOperation({ summary: 'Create a new restaurant payment method' })
|
||||||
@ApiBody({ type: CreatePaymentMethodDto })
|
@ApiBody({ type: CreatePaymentMethodDto })
|
||||||
createRestaurantPaymentMethod(, @Body() createPaymentMethodDto: CreatePaymentMethodDto) {
|
createRestaurantPaymentMethod(, @Body() createPaymentMethodDto: CreatePaymentMethodDto) {
|
||||||
return this.paymentMethodService.create(restId, createPaymentMethodDto);
|
return this.paymentMethodService.create(, createPaymentMethodDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@@ -138,6 +138,6 @@ export class PaymentsController {
|
|||||||
@ApiOperation({ summary: 'Get payment chart data with date and period filters' })
|
@ApiOperation({ summary: 'Get payment chart data with date and period filters' })
|
||||||
|
|
||||||
getPaymentChart(@Query() query: PaymentChartDto,) {
|
getPaymentChart(@Query() query: PaymentChartDto,) {
|
||||||
return this.paymentsService.getChartData(query, restId);
|
return this.paymentsService.getChartData(query,);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PaymentMethodEnum } from "../interface/payment";
|
|||||||
export class paymentSucceedEvent {
|
export class paymentSucceedEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly orderId: string,
|
public readonly orderId: string,
|
||||||
public readonly restaurantId: string,
|
public readonly: string,
|
||||||
public readonly orderNumber: string,
|
public readonly orderNumber: string,
|
||||||
public readonly paymentMethod: PaymentMethodEnum,
|
public readonly paymentMethod: PaymentMethodEnum,
|
||||||
public readonly total: number
|
public readonly total: number
|
||||||
@@ -16,7 +16,7 @@ export class onlinePaymentSucceedEvent {
|
|||||||
constructor(
|
constructor(
|
||||||
public readonly paymentId: string,
|
public readonly paymentId: string,
|
||||||
public readonly orderId: string,
|
public readonly orderId: string,
|
||||||
public readonly restaurantId: string,
|
public readonly: string,
|
||||||
public readonly orderNumber: string,
|
public readonly orderNumber: string,
|
||||||
public readonly total: number,
|
public readonly total: number,
|
||||||
) { }
|
) { }
|
||||||
|
|||||||
@@ -36,16 +36,16 @@ export class PaymentListeners {
|
|||||||
async handlePaymentSucceed(event: paymentSucceedEvent) {
|
async handlePaymentSucceed(event: paymentSucceedEvent) {
|
||||||
try {
|
try {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Payment paid event received: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
`Payment paid event received: ${event.orderId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||||
);
|
);
|
||||||
// get admnin os restuaraant that have order permissuins
|
// get admnin os restuaraant that have order permissuins
|
||||||
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.MANAGE_ORDERS);
|
const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
|
||||||
// const order=await
|
// const order=await
|
||||||
const recipients = admins.map(admin => ({
|
const recipients = admins.map(admin => ({
|
||||||
adminId: admin.id,
|
adminId: admin.id,
|
||||||
}));
|
}));
|
||||||
await this.notificationService.sendNotification({
|
await this.notificationService.sendNotification({
|
||||||
restaurantId: event.restaurantId,
|
: event.,
|
||||||
message: {
|
message: {
|
||||||
title: NotifTitleEnum.PAYMENT_SUCCESS,
|
title: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||||
content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`,
|
content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`,
|
||||||
@@ -73,7 +73,7 @@ export class PaymentListeners {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to send notification for order created event: ${event.restaurantId}`,
|
`Failed to send notification for order created event: ${event.}`,
|
||||||
error instanceof Error ? error.stack : String(error),
|
error instanceof Error ? error.stack : String(error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,16 +84,16 @@ export class PaymentListeners {
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Online payment succeed event received: ${event.paymentId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
`Online payment succeed event received: ${event.paymentId} for restaurant: ${event.} and order number: ${event.orderNumber}`,
|
||||||
);
|
);
|
||||||
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.MANAGE_ORDERS);
|
const admins = await this.adminService.findAdminsWithPermission(event., Permission.MANAGE_ORDERS);
|
||||||
const recipients = admins.map(admin => ({
|
const recipients = admins.map(admin => ({
|
||||||
adminId: admin.id,
|
adminId: admin.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// admin notifs
|
// admin notifs
|
||||||
await this.notificationService.sendNotification({
|
await this.notificationService.sendNotification({
|
||||||
restaurantId: event.restaurantId,
|
: event.,
|
||||||
message: {
|
message: {
|
||||||
title: NotifTitleEnum.ORDER_CREATED,
|
title: NotifTitleEnum.ORDER_CREATED,
|
||||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||||
@@ -120,10 +120,10 @@ export class PaymentListeners {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const order = await this.orderService.findOne(event.orderId, event.restaurantId);
|
const order = await this.orderService.findOne(event.orderId, event.);
|
||||||
if (!order) {
|
if (!order) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Order not found: ${event.orderId} for restaurant: ${event.restaurantId}`,
|
`Order not found: ${event.orderId} for restaurant: ${event.}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ export class PaymentListeners {
|
|||||||
];
|
];
|
||||||
// user notif
|
// user notif
|
||||||
await this.notificationService.sendNotification({
|
await this.notificationService.sendNotification({
|
||||||
restaurantId: event.restaurantId,
|
: event.,
|
||||||
message: {
|
message: {
|
||||||
title: NotifTitleEnum.PAYMENT_SUCCESS,
|
title: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||||
content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`,
|
content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`,
|
||||||
@@ -162,7 +162,7 @@ export class PaymentListeners {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to send notification for online payment succeed event: ${event.restaurantId}`,
|
`Failed to send notification for online payment succeed event: ${event.}`,
|
||||||
error instanceof Error ? error.stack : String(error),
|
error instanceof Error ? error.stack : String(error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ export class PaymentMethodService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly paymentMethodRepository: PaymentMethodRepository,
|
private readonly paymentMethodRepository: PaymentMethodRepository,
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async create(restId: string, createPaymentMethodDto: CreatePaymentMethodDto): Promise<PaymentMethod> {
|
async create(: string, createPaymentMethodDto: CreatePaymentMethodDto): Promise<PaymentMethod> {
|
||||||
// Check if restaurant exists
|
// Check if restaurant exists
|
||||||
const restaurant = await this.em.findOne(Restaurant, { id: restId });
|
const restaurant = await this.em.findOne(Restaurant, { id: });
|
||||||
if (!restaurant) {
|
if (!restaurant) {
|
||||||
throw new NotFoundException(RestMessage.NOT_FOUND);
|
throw new NotFoundException(RestMessage.NOT_FOUND);
|
||||||
}
|
}
|
||||||
@@ -42,8 +42,8 @@ export class PaymentMethodService {
|
|||||||
return paymentMethod;
|
return paymentMethod;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByRestaurant(restaurantId: string): Promise<PaymentMethod[]> {
|
async findByRestaurant(: string): Promise<PaymentMethod[]> {
|
||||||
return this.paymentMethodRepository.find({ restaurant: { id: restaurantId } }, { populate: ['restaurant'] });
|
return this.paymentMethodRepository.find({ restaurant: { id: } }, { populate: ['restaurant'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, updatePaymentMethodDto: UpdatePaymentMethodDto): Promise<PaymentMethod> {
|
async update(id: string, updatePaymentMethodDto: UpdatePaymentMethodDto): Promise<PaymentMethod> {
|
||||||
|
|||||||
@@ -233,10 +233,10 @@ export class PaymentsService {
|
|||||||
return payment;
|
return payment;
|
||||||
}
|
}
|
||||||
|
|
||||||
findAllPaymentsByRestaurantId(restId: string, userId: string) {
|
findAllPaymentsBy(: string, userId: string) {
|
||||||
return this.em.find(
|
return this.em.find(
|
||||||
Payment,
|
Payment,
|
||||||
{ order: { restaurant: { id: restId }, user: { id: userId } } },
|
{ order: { restaurant: { id: }, user: { id: userId } } },
|
||||||
{ populate: ['order', 'order.paymentMethod'] },
|
{ populate: ['order', 'order.paymentMethod'] },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -319,7 +319,7 @@ export class PaymentsService {
|
|||||||
return payment;
|
return payment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChartData(dto: PaymentChartDto, restaurantId: string): Promise<Array<{ date: string; cash: number; online: number }>> {
|
async getChartData(dto: PaymentChartDto, : string): Promise<Array<{ date: string; cash: number; online: number }>> {
|
||||||
const { startDate, endDate, type = ChartPeriodEnum.Daily } = dto;
|
const { startDate, endDate, type = ChartPeriodEnum.Daily } = dto;
|
||||||
|
|
||||||
const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
@@ -342,8 +342,8 @@ export class PaymentsService {
|
|||||||
const params: any[] = [startOfDay, endOfDay];
|
const params: any[] = [startOfDay, endOfDay];
|
||||||
let restaurantFilter = '';
|
let restaurantFilter = '';
|
||||||
|
|
||||||
if (restaurantId) {
|
if () {
|
||||||
params.push(restaurantId);
|
params.push();
|
||||||
restaurantFilter = `AND o.restaurant_id = ?`;
|
restaurantFilter = `AND o.restaurant_id = ?`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +365,7 @@ export class PaymentsService {
|
|||||||
ORDER BY DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp)) ASC
|
ORDER BY DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp)) ASC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.logger.debug(`Chart query params: startOfDay=${startOfDay.toISOString()}, endOfDay=${endOfDay.toISOString()}, restaurantId=${restaurantId}`);
|
this.logger.debug(`Chart query params: startOfDay=${startOfDay.toISOString()}, endOfDay=${endOfDay.toISOString()}, =${}`);
|
||||||
const result = await this.em.execute(query, params);
|
const result = await this.em.execute(query, params);
|
||||||
this.logger.debug(`Chart query returned ${result.length} rows`);
|
this.logger.debug(`Chart query returned ${result.length} rows`);
|
||||||
|
|
||||||
|
|||||||
@@ -73,14 +73,14 @@ export class productService {
|
|||||||
|
|
||||||
// async update( id: string, dto: Partial<CreateproductDto>): Promise<Product> {
|
// async update( id: string, dto: Partial<CreateproductDto>): Promise<Product> {
|
||||||
// const { categoryId, dailyStock, ...rest } = dto;
|
// const { categoryId, dailyStock, ...rest } = dto;
|
||||||
// const product = await this.productRepository.findOne({ id, restaurant: { id: restId } }, { populate: ['restaurant'] });
|
// const product = await this.productRepository.findOne({ id, restaurant: { id: } }, { populate: ['restaurant'] });
|
||||||
// if (!product) {
|
// if (!product) {
|
||||||
// throw new NotFoundException(productMessage.NOT_FOUND);
|
// throw new NotFoundException(productMessage.NOT_FOUND);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// // attach new categories if provided (adds, does not attempt to preserve/remove existing ones)
|
// // attach new categories if provided (adds, does not attempt to preserve/remove existing ones)
|
||||||
// if (categoryId) {
|
// if (categoryId) {
|
||||||
// const category = await this.categoryRepository.findOne({ id: categoryId, restaurant: { id: restId } });
|
// const category = await this.categoryRepository.findOne({ id: categoryId, restaurant: { id: } });
|
||||||
|
|
||||||
// if (!category) {
|
// if (!category) {
|
||||||
// throw new NotFoundException(CategoryMessage.NOT_FOUND);
|
// throw new NotFoundException(CategoryMessage.NOT_FOUND);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { UpdateRoleDto } from '../dto/update-role.dto';
|
|||||||
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||||
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
|
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
|
||||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
import { RestId } from 'src/common/decorators';
|
import { } from 'src/common/decorators';
|
||||||
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
||||||
import { Permission } from 'src/common/enums/permission.enum';
|
import { Permission } from 'src/common/enums/permission.enum';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export class RolesController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get all through restaurant roles with pagination and filters' })
|
@ApiOperation({ summary: 'Get all through restaurant roles with pagination and filters' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.roleService.findAllGeneralAndRestaurantRoles(restId);
|
return this.roleService.findAllGeneralAndRestaurantRoles();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('admin/roles/permissions')
|
@Get('admin/roles/permissions')
|
||||||
@@ -36,7 +36,7 @@ export class RolesController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get all permissions that the admin has' })
|
@ApiOperation({ summary: 'Get all permissions that the admin has' })
|
||||||
async findAllPermissions(@AdminId() adminId: string,) {
|
async findAllPermissions(@AdminId() adminId: string,) {
|
||||||
const adminPermissionNames = await this.permissionService.getAdminFullPermissions(adminId, restId);
|
const adminPermissionNames = await this.permissionService.getAdminFullPermissions(adminId,);
|
||||||
|
|
||||||
return adminPermissionNames;
|
return adminPermissionNames;
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ export class RolesController {
|
|||||||
@ApiOperation({ summary: 'Create a new role' })
|
@ApiOperation({ summary: 'Create a new role' })
|
||||||
@ApiBody({ type: CreateRoleDto })
|
@ApiBody({ type: CreateRoleDto })
|
||||||
create(@Body() dto: CreateRoleDto,) {
|
create(@Body() dto: CreateRoleDto,) {
|
||||||
return this.roleService.createRestaurantRole(dto, restId);
|
return this.roleService.createRestaurantRole(dto,);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('admin/roles/:id')
|
@Get('admin/roles/:id')
|
||||||
@@ -59,7 +59,7 @@ export class RolesController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get a specific role by ID' })
|
@ApiOperation({ summary: 'Get a specific role by ID' })
|
||||||
findOne(@Param('id') id: string,) {
|
findOne(@Param('id') id: string,) {
|
||||||
return this.roleService.findOne(restId, id);
|
return this.roleService.findOne(, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('admin/roles/:id')
|
@Patch('admin/roles/:id')
|
||||||
@@ -69,7 +69,7 @@ export class RolesController {
|
|||||||
@ApiOperation({ summary: 'Update a role' })
|
@ApiOperation({ summary: 'Update a role' })
|
||||||
@ApiBody({ type: UpdateRoleDto })
|
@ApiBody({ type: UpdateRoleDto })
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateRoleDto,) {
|
update(@Param('id') id: string, @Body() dto: UpdateRoleDto,) {
|
||||||
return this.roleService.update(restId, id, dto);
|
return this.roleService.update(, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('admin/roles/:id')
|
@Delete('admin/roles/:id')
|
||||||
@@ -78,7 +78,7 @@ export class RolesController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Delete a role' })
|
@ApiOperation({ summary: 'Delete a role' })
|
||||||
remove(@Param('id') id: string,) {
|
remove(@Param('id') id: string,) {
|
||||||
return this.roleService.remove(restId, id);
|
return this.roleService.remove(, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Super Admin Endpoints */
|
/** Super Admin Endpoints */
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ export class PermissionsService {
|
|||||||
/**
|
/**
|
||||||
* Get admin permissions from cache or database
|
* Get admin permissions from cache or database
|
||||||
* @param adminId - The admin ID
|
* @param adminId - The admin ID
|
||||||
* @param restId - The restaurant ID
|
* @param - The restaurant ID
|
||||||
* @returns Array of permission names (string[])
|
* @returns Array of permission names (string[])
|
||||||
*/
|
*/
|
||||||
async getAdminPermissions(adminId: string, restId: string): Promise<string[]> {
|
async getAdminPermissions(adminId: string, : string): Promise<string[]> {
|
||||||
const cacheKey = `${this.ADMIN_PERMISSIONS_KEY}:${adminId}:${restId}`;
|
const cacheKey = `${this.ADMIN_PERMISSIONS_KEY}:${adminId}:${}`;
|
||||||
|
|
||||||
// Try to get from cache first
|
// Try to get from cache first
|
||||||
const cachedPermissions = await this.cacheService.get<string>(cacheKey);
|
const cachedPermissions = await this.cacheService.get<string>(cacheKey);
|
||||||
@@ -49,7 +49,7 @@ export class PermissionsService {
|
|||||||
|
|
||||||
// If not in cache, fetch from database
|
// If not in cache, fetch from database
|
||||||
const admin = await this.adminRepository.findOne(
|
const admin = await this.adminRepository.findOne(
|
||||||
{ id: adminId, roles: { restaurant: { id: restId } } },
|
{ id: adminId, roles: { restaurant: { id: } } },
|
||||||
{ populate: ['roles', 'roles.role', 'roles.role.permissions'] },
|
{ populate: ['roles', 'roles.role', 'roles.role.permissions'] },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,14 +77,14 @@ export class PermissionsService {
|
|||||||
return permissions.flat().map(p => p.name);
|
return permissions.flat().map(p => p.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAdminFullPermissions(adminId: string, restId: string): Promise<Permission[]> {
|
async getAdminFullPermissions(adminId: string, : string): Promise<Permission[]> {
|
||||||
const listOfPermissions = []
|
const listOfPermissions = []
|
||||||
const adminRoles = await this.em.findOne(AdminRole, {
|
const adminRoles = await this.em.findOne(AdminRole, {
|
||||||
admin: {
|
admin: {
|
||||||
id: adminId,
|
id: adminId,
|
||||||
},
|
},
|
||||||
restaurant: {
|
restaurant: {
|
||||||
id: restId,
|
id: ,
|
||||||
},
|
},
|
||||||
}, { populate: ['role', 'role.permissions'] });
|
}, { populate: ['role', 'role.permissions'] });
|
||||||
if (adminRoles) {
|
if (adminRoles) {
|
||||||
@@ -96,10 +96,10 @@ export class PermissionsService {
|
|||||||
/**
|
/**
|
||||||
* Invalidate admin permissions cache
|
* Invalidate admin permissions cache
|
||||||
* @param adminId - The admin ID
|
* @param adminId - The admin ID
|
||||||
* @param restId - The restaurant ID
|
* @param - The restaurant ID
|
||||||
*/
|
*/
|
||||||
async invalidateAdminPermissionsCache(adminId: string, restId: string): Promise<void> {
|
async invalidateAdminPermissionsCache(adminId: string, : string): Promise<void> {
|
||||||
const cacheKey = `${this.ADMIN_PERMISSIONS_KEY}:${adminId}:${restId}`;
|
const cacheKey = `${this.ADMIN_PERMISSIONS_KEY}:${adminId}:${}`;
|
||||||
await this.cacheService.del(cacheKey);
|
await this.cacheService.del(cacheKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,18 +19,18 @@ export class RolesService {
|
|||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async createRestaurantRole(dto: CreateRoleDto, restId: string) {
|
async createRestaurantRole(dto: CreateRoleDto, : string) {
|
||||||
const { name, permissionIds } = dto;
|
const { name, permissionIds } = dto;
|
||||||
|
|
||||||
// Check if role already exists
|
// Check if role already exists
|
||||||
const existing = await this.roleRepository.findOne({ name, restaurant: restId ? { id: restId } : null });
|
const existing = await this.roleRepository.findOne({ name, restaurant: ? { id: } : null });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new BadRequestException('Role with this name already exists for the restaurant');
|
throw new BadRequestException('Role with this name already exists for the restaurant');
|
||||||
}
|
}
|
||||||
|
|
||||||
let restaurant: Restaurant | null = null;
|
let restaurant: Restaurant | null = null;
|
||||||
if (restId) {
|
if () {
|
||||||
restaurant = await this.em.findOne(Restaurant, { id: restId });
|
restaurant = await this.em.findOne(Restaurant, { id: });
|
||||||
if (!restaurant) {
|
if (!restaurant) {
|
||||||
throw new NotFoundException('Restaurant not found');
|
throw new NotFoundException('Restaurant not found');
|
||||||
}
|
}
|
||||||
@@ -55,8 +55,8 @@ export class RolesService {
|
|||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllGeneralAndRestaurantRoles(restId: string) {
|
async findAllGeneralAndRestaurantRoles(: string) {
|
||||||
const where: FilterQuery<Role> = { $or: [{ restaurant: restId }, { restaurant: null }], isSystem: false };
|
const where: FilterQuery<Role> = { $or: [{ restaurant: }, { restaurant: null }], isSystem: false };
|
||||||
|
|
||||||
const roles = await this.roleRepository.find(where, {
|
const roles = await this.roleRepository.find(where, {
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -66,9 +66,9 @@ export class RolesService {
|
|||||||
return roles;
|
return roles;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(restId: string, id: string) {
|
async findOne(: string, id: string) {
|
||||||
const role = await this.roleRepository.findOne(
|
const role = await this.roleRepository.findOne(
|
||||||
{ id, restaurant: { id: restId } },
|
{ id, restaurant: { id: } },
|
||||||
{ populate: ['permissions', 'restaurant'] },
|
{ populate: ['permissions', 'restaurant'] },
|
||||||
);
|
);
|
||||||
if (!role) {
|
if (!role) {
|
||||||
@@ -77,9 +77,9 @@ export class RolesService {
|
|||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(restId: string, id: string, dto: UpdateRoleDto) {
|
async update(: string, id: string, dto: UpdateRoleDto) {
|
||||||
const role = await this.roleRepository.findOne(
|
const role = await this.roleRepository.findOne(
|
||||||
{ id, restaurant: { id: restId } },
|
{ id, restaurant: { id: } },
|
||||||
{ populate: ['permissions', 'restaurant'] },
|
{ populate: ['permissions', 'restaurant'] },
|
||||||
);
|
);
|
||||||
if (!role) {
|
if (!role) {
|
||||||
@@ -116,9 +116,9 @@ export class RolesService {
|
|||||||
return roles;
|
return roles;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(restId: string, id: string) {
|
async remove(: string, id: string) {
|
||||||
const role = await this.roleRepository.findOne(
|
const role = await this.roleRepository.findOne(
|
||||||
{ id, restaurant: { id: restId } },
|
{ id, restaurant: { id: } },
|
||||||
{ populate: ['permissions', 'restaurant'] },
|
{ populate: ['permissions', 'restaurant'] },
|
||||||
);
|
);
|
||||||
if (!role) {
|
if (!role) {
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Controller, Get, UseGuards, Patch, Body, ValidationPipe, Post, Query, Delete, Param } from '@nestjs/common';
|
import { Controller, Get, UseGuards, Patch, Body} from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody, ApiOkResponse, ApiHeader } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody, ApiOkResponse } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
||||||
import { UserService } from '../providers/user.service';
|
import { UserService } from '../providers/user.service';
|
||||||
|
import { CreateUserDto } from '../dto/create-user.dto';
|
||||||
import { UpdateUserDto } from '../dto/update-user.dto';
|
import { UpdateUserDto } from '../dto/update-user.dto';
|
||||||
import { CreateUserAddressDto } from '../dto/create-user-address.dto';
|
import { CreateUserAddressDto } from '../dto/create-user-address.dto';
|
||||||
import { UpdateUserAddressDto } from '../dto/update-user-address.dto';
|
import { UpdateUserAddressDto } from '../dto/update-user-address.dto';
|
||||||
import { UserId } from 'src/common/decorators/user-id.decorator';
|
import { UserId } from 'src/common/decorators/user-id.decorator';
|
||||||
import { RestId } from 'src/common/decorators';
|
import { } from 'src/common/decorators';
|
||||||
import { FindUsersDto } from '../dto/find-user.dto';
|
import { FindUsersDto } from '../dto/find-user.dto';
|
||||||
import { FindWalletTransactionsDto } from '../dto/find-wallet-transactions.dto';
|
import { FindWalletTransactionsDto } from '../dto/find-wallet-transactions.dto';
|
||||||
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||||
import { API_HEADER_SLUG } from 'src/common/constants/index';
|
|
||||||
import { UserSuccessMessage } from 'src/common/enums/message.enum';
|
import { UserSuccessMessage } from 'src/common/enums/message.enum';
|
||||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
import { Permission } from 'src/common/enums/permission.enum';
|
import { Permission } from 'src/common/enums/permission.enum';
|
||||||
@@ -40,7 +40,7 @@ export class UsersController {
|
|||||||
@ApiBody({ type: UpdateUserDto })
|
@ApiBody({ type: UpdateUserDto })
|
||||||
@Patch('public/user/update')
|
@Patch('public/user/update')
|
||||||
async updateUser(@UserId() userId: string, , @Body() dto: UpdateUserDto) {
|
async updateUser(@UserId() userId: string, , @Body() dto: UpdateUserDto) {
|
||||||
const user = await this.userService.updateUser(userId, restId, dto);
|
const user = await this.userService.updateUser(userId, , dto);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,129 +55,128 @@ export class UsersController {
|
|||||||
return addresses;
|
return addresses;
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @UseGuards(AuthGuard)
|
||||||
@ApiBearerAuth()
|
// @ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get User Wallet' })
|
// @ApiOperation({ summary: 'Get User Wallet' })
|
||||||
|
|
||||||
@Get('public/user/wallet/balance')
|
// @Get('public/user/wallet/balance')
|
||||||
async getUserWalletBalance(@UserId() userId: string,) {
|
// async getUserWalletBalance(@UserId() userId: string,) {
|
||||||
const wallet = await this.walletService.getUserCurrentWalletBalance(userId, restId);
|
// const wallet = await this.walletService.getUserCurrentWalletBalance(userId,);
|
||||||
return wallet;
|
// return wallet;
|
||||||
}
|
// }
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @UseGuards(AuthGuard)
|
||||||
@ApiBearerAuth()
|
// @ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get User Wallet' })
|
// @ApiOperation({ summary: 'Get User Wallet' })
|
||||||
|
// @Get('public/user/points/balance')
|
||||||
|
// async getUserWallet(@UserId() userId: string,) {
|
||||||
|
// const wallet = await this.walletService.getUserCurrentPoinrBalance(userId,);
|
||||||
|
// return wallet;
|
||||||
|
// }
|
||||||
|
|
||||||
@Get('public/user/points/balance')
|
// @UseGuards(AuthGuard)
|
||||||
async getUserWallet(@UserId() userId: string,) {
|
// @ApiBearerAuth()
|
||||||
const wallet = await this.walletService.getUserCurrentPoinrBalance(userId, restId);
|
// @ApiOperation({ summary: 'Get User Wallet Transactions' })
|
||||||
return wallet;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @Get('public/user/wallet/transactions')
|
||||||
@ApiBearerAuth()
|
// async getUserWalletTransactions(
|
||||||
@ApiOperation({ summary: 'Get User Wallet Transactions' })
|
// @UserId() userId: string,
|
||||||
|
// ,
|
||||||
|
// @Query(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
|
// query: FindWalletTransactionsDto,
|
||||||
|
// ) {
|
||||||
|
// const transactions = await this.userService.getUserWalletTransactions(userId, , query);
|
||||||
|
// return transactions;
|
||||||
|
// }
|
||||||
|
|
||||||
@Get('public/user/wallet/transactions')
|
// @UseGuards(AuthGuard)
|
||||||
async getUserWalletTransactions(
|
// @ApiBearerAuth()
|
||||||
@UserId() userId: string,
|
// @ApiOperation({ summary: 'Create a new address for the authenticated user' })
|
||||||
,
|
|
||||||
@Query(new ValidationPipe({ transform: true, whitelist: true }))
|
|
||||||
query: FindWalletTransactionsDto,
|
|
||||||
) {
|
|
||||||
const transactions = await this.userService.getUserWalletTransactions(userId, restId, query);
|
|
||||||
return transactions;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @ApiBody({ type: CreateUserAddressDto })
|
||||||
@ApiBearerAuth()
|
// @ApiOkResponse({ description: 'Created user address' })
|
||||||
@ApiOperation({ summary: 'Create a new address for the authenticated user' })
|
// @Post('public/user/addresses')
|
||||||
|
// async createUserAddress(
|
||||||
|
// @UserId() userId: string,
|
||||||
|
// @Body(new ValidationPipe({ transform: true, whitelist: true })) dto: CreateUserAddressDto,
|
||||||
|
// ) {
|
||||||
|
// const address = await this.userService.createUserAddress(userId, dto);
|
||||||
|
// return address;
|
||||||
|
// }
|
||||||
|
|
||||||
@ApiBody({ type: CreateUserAddressDto })
|
// @UseGuards(AuthGuard)
|
||||||
@ApiOkResponse({ description: 'Created user address' })
|
// @ApiBearerAuth()
|
||||||
@Post('public/user/addresses')
|
// @ApiOperation({ summary: 'Get a single address by ID for the authenticated user' })
|
||||||
async createUserAddress(
|
|
||||||
@UserId() userId: string,
|
|
||||||
@Body(new ValidationPipe({ transform: true, whitelist: true })) dto: CreateUserAddressDto,
|
|
||||||
) {
|
|
||||||
const address = await this.userService.createUserAddress(userId, dto);
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @ApiOkResponse({ description: 'User address details' })
|
||||||
@ApiBearerAuth()
|
// @Get('public/user/addresses/:addressId')
|
||||||
@ApiOperation({ summary: 'Get a single address by ID for the authenticated user' })
|
// async getUserAddress(@UserId() userId: string, @Param('addressId') addressId: string) {
|
||||||
|
// const address = await this.userService.getUserAddress(userId, addressId);
|
||||||
|
// return address;
|
||||||
|
// }
|
||||||
|
|
||||||
@ApiOkResponse({ description: 'User address details' })
|
// @UseGuards(AuthGuard)
|
||||||
@Get('public/user/addresses/:addressId')
|
// @ApiBearerAuth()
|
||||||
async getUserAddress(@UserId() userId: string, @Param('addressId') addressId: string) {
|
// @ApiOperation({ summary: 'Update an address for the authenticated user' })
|
||||||
const address = await this.userService.getUserAddress(userId, addressId);
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @ApiBody({ type: UpdateUserAddressDto })
|
||||||
@ApiBearerAuth()
|
// @ApiOkResponse({ description: 'Updated user address' })
|
||||||
@ApiOperation({ summary: 'Update an address for the authenticated user' })
|
// @Patch('public/user/addresses/:addressId')
|
||||||
|
// async updateUserAddress(
|
||||||
|
// @UserId() userId: string,
|
||||||
|
// @Param('addressId') addressId: string,
|
||||||
|
// @Body(new ValidationPipe({ transform: true, whitelist: true })) dto: UpdateUserAddressDto,
|
||||||
|
// ) {
|
||||||
|
// const address = await this.userService.updateUserAddress(userId, addressId, dto);
|
||||||
|
// return address;
|
||||||
|
// }
|
||||||
|
|
||||||
@ApiBody({ type: UpdateUserAddressDto })
|
// @UseGuards(AuthGuard)
|
||||||
@ApiOkResponse({ description: 'Updated user address' })
|
// @ApiBearerAuth()
|
||||||
@Patch('public/user/addresses/:addressId')
|
// @ApiOperation({ summary: 'Delete an address for the authenticated user' })
|
||||||
async updateUserAddress(
|
|
||||||
@UserId() userId: string,
|
|
||||||
@Param('addressId') addressId: string,
|
|
||||||
@Body(new ValidationPipe({ transform: true, whitelist: true })) dto: UpdateUserAddressDto,
|
|
||||||
) {
|
|
||||||
const address = await this.userService.updateUserAddress(userId, addressId, dto);
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @ApiOkResponse({ description: 'Address deleted successfully' })
|
||||||
@ApiBearerAuth()
|
// @Delete('public/user/addresses/:addressId')
|
||||||
@ApiOperation({ summary: 'Delete an address for the authenticated user' })
|
// async deleteUserAddress(@UserId() userId: string, @Param('addressId') addressId: string) {
|
||||||
|
// await this.userService.deleteUserAddress(userId, addressId);
|
||||||
|
// return { message: UserSuccessMessage.ADDRESS_DELETED_SUCCESS };
|
||||||
|
// }
|
||||||
|
|
||||||
@ApiOkResponse({ description: 'Address deleted successfully' })
|
// @UseGuards(AuthGuard)
|
||||||
@Delete('public/user/addresses/:addressId')
|
// @ApiBearerAuth()
|
||||||
async deleteUserAddress(@UserId() userId: string, @Param('addressId') addressId: string) {
|
// @ApiOperation({ summary: 'Set an address as default for the authenticated user' })
|
||||||
await this.userService.deleteUserAddress(userId, addressId);
|
|
||||||
return { message: UserSuccessMessage.ADDRESS_DELETED_SUCCESS };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @ApiOkResponse({ description: 'Address set as default' })
|
||||||
@ApiBearerAuth()
|
// @Patch('public/user/addresses/:addressId/default')
|
||||||
@ApiOperation({ summary: 'Set an address as default for the authenticated user' })
|
// async setDefaultAddress(@UserId() userId: string, @Param('addressId') addressId: string) {
|
||||||
|
// const address = await this.userService.setDefaultAddress(userId, addressId);
|
||||||
|
// return address;
|
||||||
|
// }
|
||||||
|
|
||||||
@ApiOkResponse({ description: 'Address set as default' })
|
// @UseGuards(AuthGuard)
|
||||||
@Patch('public/user/addresses/:addressId/default')
|
// @ApiBearerAuth()
|
||||||
async setDefaultAddress(@UserId() userId: string, @Param('addressId') addressId: string) {
|
// @ApiOperation({ summary: 'Convert user score (points) to wallet balance' })
|
||||||
const address = await this.userService.setDefaultAddress(userId, addressId);
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
// @Post('public/user/convert-score-to-wallet')
|
||||||
@ApiBearerAuth()
|
// async convertScoreToWallet(@UserId() userId: string,) {
|
||||||
@ApiOperation({ summary: 'Convert user score (points) to wallet balance' })
|
// await this.userService.convertScoreToWallet(userId,);
|
||||||
|
// return {
|
||||||
|
// message: UserSuccessMessage.SCORE_CONVERTED_SUCCESS,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
@Post('public/user/convert-score-to-wallet')
|
// /******************** Admin Routes **********************/
|
||||||
async convertScoreToWallet(@UserId() userId: string,) {
|
|
||||||
await this.userService.convertScoreToWallet(userId, restId);
|
|
||||||
return {
|
|
||||||
message: UserSuccessMessage.SCORE_CONVERTED_SUCCESS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************** Admin Routes **********************/
|
// @UseGuards(AdminAuthGuard)
|
||||||
|
// @ApiBearerAuth()
|
||||||
@UseGuards(AdminAuthGuard)
|
// @Permissions(Permission.MANAGE_USERS)
|
||||||
@ApiBearerAuth()
|
// @ApiOperation({ summary: 'Get paginated list of restuarant users with filters' })
|
||||||
@Permissions(Permission.MANAGE_USERS)
|
// @Get('admin/users')
|
||||||
@ApiOperation({ summary: 'Get paginated list of restuarant users with filters' })
|
// async findAllRestaurantUsers(
|
||||||
@Get('admin/users')
|
// @Query(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
async findAllRestaurantUsers(
|
// query: FindUsersDto,
|
||||||
@Query(new ValidationPipe({ transform: true, whitelist: true }))
|
// ,
|
||||||
query: FindUsersDto,
|
// ) {
|
||||||
,
|
// return this.userService.findAll(, query);
|
||||||
) {
|
// }
|
||||||
return this.userService.findAll(restId, query);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { IsString, IsBoolean, IsNotEmpty, IsMobilePhone, IsInt } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
|
||||||
|
@IsMobilePhone('fa-IR')
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "User's first name", example: 'علی' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "User's last name", example: 'جعفری' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Gender flag (boolean)', example: true })
|
||||||
|
@IsBoolean()
|
||||||
|
gender: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "User's max credit in Toman", example: 100000 })
|
||||||
|
@IsInt()
|
||||||
|
maxCredit: number;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { IsNotEmpty, IsNumber, IsIn } from 'class-validator';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { WalletTransactionType, WalletTransactionReason } from '../interface/wallet';
|
|
||||||
|
|
||||||
export class CreateWalletTransactionDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Transaction amount',
|
|
||||||
type: Number,
|
|
||||||
example: 1000,
|
|
||||||
})
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsNumber()
|
|
||||||
@Type(() => Number)
|
|
||||||
amount!: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Transaction type',
|
|
||||||
enum: WalletTransactionType,
|
|
||||||
example: WalletTransactionType.CREDIT,
|
|
||||||
})
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsIn(Object.values(WalletTransactionType))
|
|
||||||
type!: WalletTransactionType;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Transaction reason',
|
|
||||||
enum: WalletTransactionReason,
|
|
||||||
example: WalletTransactionReason.ORDER_PAYMENT,
|
|
||||||
})
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsIn(Object.values(WalletTransactionReason))
|
|
||||||
reason!: WalletTransactionReason;
|
|
||||||
}
|
|
||||||
@@ -1,35 +1,4 @@
|
|||||||
import { IsString, IsOptional, IsBoolean } from 'class-validator';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { CreateUserDto } from './create-user.dto';
|
||||||
|
|
||||||
export class UpdateUserDto {
|
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||||
@ApiPropertyOptional({ description: "User's first name", example: 'John' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
firstName?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "User's last name", example: 'Doe' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
lastName?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "User's birth date (ISO)", example: '1990-01-01' })
|
|
||||||
@IsOptional()
|
|
||||||
// keep as string here, caller should send ISO date; service will assign to Date
|
|
||||||
@IsString()
|
|
||||||
birthDate?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "User's marriage date (ISO)", example: '2015-06-01' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
marriageDate?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Gender flag (boolean)', example: true })
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
gender?: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Avatar URL' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
avatarUrl?: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Entity, Property, ManyToOne, Index, Enum } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
|
import { User } from './user.entity';
|
||||||
|
import { CreditTransactionType } from '../interface/credit';
|
||||||
|
|
||||||
|
@Entity({ tableName: 'credit_transactions' })
|
||||||
|
@Index({ properties: ['user'] })
|
||||||
|
export class CreditTransaction extends BaseEntity {
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
user!: User;
|
||||||
|
|
||||||
|
@Property()
|
||||||
|
orderId: string
|
||||||
|
|
||||||
|
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||||
|
balance!: number;
|
||||||
|
|
||||||
|
@Enum(() => CreditTransaction)
|
||||||
|
type!: CreditTransactionType;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { Entity, Property, ManyToOne, Index, Enum } from '@mikro-orm/core';
|
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
|
||||||
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
|
|
||||||
import { User } from './user.entity';
|
|
||||||
import { PointTransactionReason } from '../interface/point';
|
|
||||||
import { PointTransactionType } from '../interface/point';
|
|
||||||
|
|
||||||
@Entity({ tableName: 'point_transactions' })
|
|
||||||
@Index({ properties: ['user', 'restaurant'] }) // Composite index for most common query: find wallet by user and restaurant
|
|
||||||
@Index({ properties: ['user'] }) // Index for queries finding all wallets for a user
|
|
||||||
@Index({ properties: ['restaurant'] }) // Index for queries finding all wallets for a restaurant
|
|
||||||
export class PointTransaction extends BaseEntity {
|
|
||||||
@ManyToOne(() => User)
|
|
||||||
user!: User;
|
|
||||||
|
|
||||||
@ManyToOne(() => User)
|
|
||||||
restaurant!: Restaurant;
|
|
||||||
|
|
||||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
|
||||||
amount!: number;
|
|
||||||
|
|
||||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
|
||||||
balance!: number;
|
|
||||||
|
|
||||||
@Enum(() => PointTransaction)
|
|
||||||
type!: PointTransactionType;
|
|
||||||
|
|
||||||
@Enum(() => PointTransactionReason)
|
|
||||||
reason!: PointTransactionReason;
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { Entity, Index, Property, OneToMany, Collection, Cascade } from '@mikro-orm/core';
|
import { Entity, Index, Property, OneToMany, Collection, Cascade, PrimaryKey } from '@mikro-orm/core';
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
import { UserAddress } from './user-address.entity';
|
import { UserAddress } from './user-address.entity';
|
||||||
import { Order } from 'src/modules/order/entities/order.entity';
|
import { Order } from 'src/modules/order/entities/order.entity';
|
||||||
import { normalizePhone } from '../../util/phone.util';
|
import { normalizePhone } from '../../util/phone.util';
|
||||||
|
import { ulid } from 'ulid';
|
||||||
|
|
||||||
@Entity({ tableName: 'users' })
|
@Entity({ tableName: 'users' })
|
||||||
@Index({ properties: ['isActive'] })
|
|
||||||
export class User extends BaseEntity {
|
export class User extends BaseEntity {
|
||||||
@OneToMany(() => Order, order => order.user)
|
@OneToMany(() => Order, order => order.user)
|
||||||
orders = new Collection<Order>(this);
|
orders = new Collection<Order>(this);
|
||||||
|
|
||||||
|
@PrimaryKey({ type: 'string', columnType: 'char(26)' })
|
||||||
|
id: string = ulid();
|
||||||
|
|
||||||
@Property()
|
@Property()
|
||||||
firstName!: string;
|
firstName!: string;
|
||||||
|
|
||||||
@@ -27,14 +30,6 @@ export class User extends BaseEntity {
|
|||||||
this._phone = normalizePhone(value);
|
this._phone = normalizePhone(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Property({ default: null, type: 'date', nullable: true })
|
|
||||||
birthDate?: Date;
|
|
||||||
|
|
||||||
@Property({ default: null, type: 'date', nullable: true })
|
|
||||||
marriageDate?: Date;
|
|
||||||
|
|
||||||
@Property({ nullable: true })
|
|
||||||
referrer?: string;
|
|
||||||
|
|
||||||
@Property({ default: true })
|
@Property({ default: true })
|
||||||
isActive?: boolean = true;
|
isActive?: boolean = true;
|
||||||
@@ -42,8 +37,9 @@ export class User extends BaseEntity {
|
|||||||
@Property({ default: true, nullable: true })
|
@Property({ default: true, nullable: true })
|
||||||
gender?: boolean;
|
gender?: boolean;
|
||||||
|
|
||||||
@Property({ nullable: true })
|
|
||||||
avatarUrl?: string;
|
@Property({ default: 0 })
|
||||||
|
maxCredit: number;
|
||||||
|
|
||||||
@OneToMany(() => UserAddress, address => address.user, {
|
@OneToMany(() => UserAddress, address => address.user, {
|
||||||
cascade: [Cascade.ALL],
|
cascade: [Cascade.ALL],
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Entity, Index, Property, ManyToOne, Enum } from '@mikro-orm/core';
|
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
|
||||||
import { User } from './user.entity';
|
|
||||||
import { WalletTransactionReason, WalletTransactionType } from '../interface/wallet';
|
|
||||||
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
|
|
||||||
|
|
||||||
@Entity({ tableName: 'wallet_transactions' })
|
|
||||||
@Index({ properties: ['user', 'restaurant'] })
|
|
||||||
@Index({ properties: ['user'] })
|
|
||||||
@Index({ properties: ['restaurant'] })
|
|
||||||
export class WalletTransaction extends BaseEntity {
|
|
||||||
@ManyToOne(() => User)
|
|
||||||
user!: User;
|
|
||||||
|
|
||||||
@ManyToOne(() => Restaurant)
|
|
||||||
restaurant!: Restaurant;
|
|
||||||
|
|
||||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
|
||||||
amount!: number;
|
|
||||||
|
|
||||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
|
||||||
balance!: number;
|
|
||||||
|
|
||||||
@Enum(() => WalletTransactionType)
|
|
||||||
type!: WalletTransactionType;
|
|
||||||
|
|
||||||
@Enum(() => WalletTransactionReason)
|
|
||||||
reason!: WalletTransactionReason;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum CreditTransactionType {
|
||||||
|
WITHDRAW = 'withdraw',
|
||||||
|
DEPOSIT = 'deposit',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export enum PointTransactionType {
|
|
||||||
CREDIT = 'credit',
|
|
||||||
DEBIT = 'debit',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export enum PointTransactionReason {
|
|
||||||
ORDER_COMPLETED_DEPOSIT = 'order_completed_deposit',
|
|
||||||
CONVERT_SCORE_TO_POINT = 'convert_score_to_point',
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export enum WalletTransactionType {
|
|
||||||
CREDIT = 'credit',
|
|
||||||
DEBIT = 'debit',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export enum WalletTransactionReason {
|
|
||||||
ORDER_PAYMENT = 'order_payment',
|
|
||||||
ORDER_REFUND = 'order_refund',
|
|
||||||
CONVERT_SCORE_TO_WALLET = 'convert_score_to_wallet',
|
|
||||||
WITHDRAW = 'withdraw',
|
|
||||||
DEPOSIT = 'deposit',
|
|
||||||
}
|
|
||||||
@@ -1,71 +1,46 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { FilterQuery, RequiredEntityData } from '@mikro-orm/core';
|
import { RequiredEntityData } from '@mikro-orm/core';
|
||||||
import { User } from '../entities/user.entity';
|
import { User } from '../entities/user.entity';
|
||||||
import { UserAddress } from '../entities/user-address.entity';
|
import { UserAddress } from '../entities/user-address.entity';
|
||||||
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
|
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { UpdateUserDto } from '../dto/update-user.dto';
|
import { CreateUserDto } from '../dto/create-user.dto';
|
||||||
import { FindUsersDto } from '../dto/find-user.dto';
|
import { FindUsersDto } from '../dto/find-user.dto';
|
||||||
import { FindWalletTransactionsDto } from '../dto/find-wallet-transactions.dto';
|
|
||||||
import { CreateWalletTransactionDto } from '../dto/create-wallet-transaction.dto';
|
|
||||||
import { CreateUserAddressDto } from '../dto/create-user-address.dto';
|
|
||||||
import { UpdateUserAddressDto } from '../dto/update-user-address.dto';
|
|
||||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
import { UserRepository } from '../repositories/user.repository';
|
import { UserRepository } from '../repositories/user.repository';
|
||||||
import { normalizePhone } from '../../util/phone.util';
|
import { normalizePhone } from '../../util/phone.util';
|
||||||
import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
|
import { UpdateUserDto } from '../dto/update-user.dto';
|
||||||
import { WalletTransactionRepository } from '../repositories/wallet-transaction.repository';
|
|
||||||
import { WalletService } from './wallet.service';
|
|
||||||
import { WalletTransactionReason, WalletTransactionType } from '../interface/wallet';
|
|
||||||
import { WalletTransaction } from '../entities/wallet-transaction.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
private readonly restaurantRepository: RestRepository,
|
|
||||||
private readonly walletTransactionRepository: WalletTransactionRepository,
|
|
||||||
private readonly walletService: WalletService,
|
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async findOrCreateByPhone(phone: string): Promise<User> {
|
async create(dto: CreateUserDto): Promise<User> {
|
||||||
const normalizedPhone = normalizePhone(phone);
|
const normalizedPhone = normalizePhone(dto.phone)
|
||||||
let user = await this.userRepository.findOne({ phone: normalizedPhone });
|
|
||||||
|
|
||||||
if (!user) {
|
const currentUser = await this.userRepository.findOne({ phone: normalizedPhone });
|
||||||
// firstName is required on the entity; use phone as a sensible default
|
|
||||||
const _raw = {
|
|
||||||
phone: normalizedPhone,
|
|
||||||
firstName: '[نام]',
|
|
||||||
// keep dates undefined (no value) — cast through unknown to satisfy TS typing
|
|
||||||
birthDate: undefined,
|
|
||||||
marriageDate: undefined,
|
|
||||||
wallet: 0,
|
|
||||||
points: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const createData = _raw as unknown as RequiredEntityData<User>;
|
if (currentUser) {
|
||||||
|
return currentUser
|
||||||
user = this.userRepository.create(createData);
|
|
||||||
await this.em.persistAndFlush(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createData: RequiredEntityData<User> = {
|
||||||
|
phone: normalizedPhone,
|
||||||
|
firstName: dto.firstName,
|
||||||
|
lastName: dto.lastName,
|
||||||
|
gender: dto.gender,
|
||||||
|
maxCredit: dto.maxCredit
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = this.userRepository.create(createData);
|
||||||
|
|
||||||
|
await this.em.persistAndFlush([user]);
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
// async updateUser(userId: string, dto: UpdateUserDto): Promise<User> {
|
|
||||||
// const user = await this.em.findOneOrFail(User, userId, {}).catch(() => {
|
|
||||||
// throw new NotFoundException(`User with ID ${userId} not found.`);
|
|
||||||
// });
|
|
||||||
|
|
||||||
// this.em.assign(user, { ...dto });
|
|
||||||
|
|
||||||
// await this.em.flush();
|
|
||||||
|
|
||||||
// return user;
|
|
||||||
// }
|
|
||||||
|
|
||||||
async findByPhone(phone: string): Promise<User | null> {
|
async findByPhone(phone: string): Promise<User | null> {
|
||||||
const normalizedPhone = normalizePhone(phone);
|
const normalizedPhone = normalizePhone(phone);
|
||||||
return this.userRepository.findOne({ phone: normalizedPhone });
|
return this.userRepository.findOne({ phone: normalizedPhone });
|
||||||
@@ -74,107 +49,24 @@ export class UserService {
|
|||||||
async findById(id: string): Promise<User | null> {
|
async findById(id: string): Promise<User | null> {
|
||||||
return this.userRepository.findOne({ id });
|
return this.userRepository.findOne({ id });
|
||||||
}
|
}
|
||||||
// todo : transaction code here
|
|
||||||
async create(phone: string, restId: string): Promise<User> {
|
|
||||||
const normalizedPhone = normalizePhone(phone);
|
|
||||||
const _raw = {
|
|
||||||
phone: normalizedPhone,
|
|
||||||
firstName: normalizedPhone,
|
|
||||||
birthDate: undefined,
|
|
||||||
marriageDate: undefined,
|
|
||||||
};
|
|
||||||
// get registerscore from restaurant
|
|
||||||
const restaurant = await this.restaurantRepository.findOne({ id: restId });
|
|
||||||
if (!restaurant) {
|
|
||||||
throw new NotFoundException(`Restaurant not found.`);
|
|
||||||
}
|
|
||||||
const points = Number(restaurant.score?.registerScore || 0);
|
|
||||||
const createData = _raw as unknown as RequiredEntityData<User>;
|
|
||||||
|
|
||||||
const user = this.userRepository.create(createData);
|
async updateUser(userId: string, dto: UpdateUserDto): Promise<User> {
|
||||||
const newWalletTransaction = this.walletTransactionRepository.create({
|
|
||||||
user: user,
|
|
||||||
restaurant: restaurant,
|
|
||||||
balance: 0,
|
|
||||||
amount: points,
|
|
||||||
type: WalletTransactionType.CREDIT,
|
|
||||||
reason: WalletTransactionReason.DEPOSIT
|
|
||||||
});
|
|
||||||
await this.em.persistAndFlush([user, newWalletTransaction]);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUser(userId: string, restId: string, dto: UpdateUserDto): Promise<User> {
|
|
||||||
const user = await this.userRepository.findOne({ id: userId });
|
const user = await this.userRepository.findOne({ id: userId });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException(`User with ID ${userId} not found.`);
|
throw new NotFoundException(`User with ID ${userId} not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize date strings into Date objects if provided
|
this.em.assign(user, dto);
|
||||||
const assignData: Partial<User> = { ...dto } as Partial<User>;
|
|
||||||
if (dto.birthDate) {
|
|
||||||
assignData.birthDate = new Date(dto.birthDate);
|
|
||||||
}
|
|
||||||
if (dto.marriageDate) {
|
|
||||||
assignData.marriageDate = new Date(dto.marriageDate);
|
|
||||||
}
|
|
||||||
// get restuarant
|
|
||||||
this.em.assign(user, assignData);
|
|
||||||
await this.em.flush();
|
await this.em.flush();
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(restId: string, dto: FindUsersDto): Promise<PaginatedResult<User>> {
|
async findAll(dto: FindUsersDto): Promise<PaginatedResult<User>> {
|
||||||
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc' } = dto;
|
return this.userRepository.findAllPaginated(dto)
|
||||||
|
|
||||||
// 1. Calculate pagination
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
// 2. Build the 'where' filter query
|
|
||||||
const where: FilterQuery<User> = {
|
|
||||||
orders: {
|
|
||||||
restaurant: {
|
|
||||||
id: restId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. Add 'search' logic (case-insensitive)
|
|
||||||
if (search) {
|
|
||||||
const searchPattern = `%${search}%`;
|
|
||||||
const normalizedSearch = normalizePhone(search);
|
|
||||||
const normalizedSearchPattern = `%${normalizedSearch}%`;
|
|
||||||
// $ilike is case-insensitive (PostgreSQL). Use $like for MySQL (often CI by default)
|
|
||||||
// Search with both original and normalized phone patterns to handle various input formats
|
|
||||||
where.$or = [
|
|
||||||
{ firstName: { $ilike: searchPattern } },
|
|
||||||
{ lastName: { $ilike: searchPattern } },
|
|
||||||
{ phone: { $ilike: searchPattern } },
|
|
||||||
...(normalizedSearch !== search ? [{ phone: { $ilike: normalizedSearchPattern } }] : []),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Execute the query using findAndCount
|
|
||||||
const [users, total] = await this.userRepository.findAndCount(where, {
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Calculate total pages
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
|
||||||
|
|
||||||
// 7. Return the paginated result
|
|
||||||
return {
|
|
||||||
data: users,
|
|
||||||
meta: {
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserAddresses(userId: string): Promise<UserAddress[]> {
|
async getUserAddresses(userId: string): Promise<UserAddress[]> {
|
||||||
@@ -188,201 +80,124 @@ export class UserService {
|
|||||||
return user.addresses.getItems();
|
return user.addresses.getItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUserAddress(userId: string, dto: CreateUserAddressDto): Promise<UserAddress> {
|
// async createUserAddress(userId: string, dto: CreateUserAddressDto): Promise<UserAddress> {
|
||||||
const user = await this.userRepository.findOne({ id: userId });
|
// const user = await this.userRepository.findOne({ id: userId });
|
||||||
if (!user) {
|
// if (!user) {
|
||||||
throw new NotFoundException(`User with ID ${userId} not found.`);
|
// throw new NotFoundException(`User with ID ${userId} not found.`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// If setting as default, unset other default addresses
|
// // If setting as default, unset other default addresses
|
||||||
if (dto.isDefault) {
|
// if (dto.isDefault) {
|
||||||
const existingAddresses = await this.em.find(UserAddress, { user: { id: userId }, isDefault: true });
|
// const existingAddresses = await this.em.find(UserAddress, { user: { id: userId }, isDefault: true });
|
||||||
for (const address of existingAddresses) {
|
// for (const address of existingAddresses) {
|
||||||
address.isDefault = false;
|
// address.isDefault = false;
|
||||||
}
|
// }
|
||||||
await this.em.flush();
|
// await this.em.flush();
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Create new address
|
// // Create new address
|
||||||
const address = this.em.create(UserAddress, {
|
// const address = this.em.create(UserAddress, {
|
||||||
user,
|
// user,
|
||||||
title: dto.title,
|
// title: dto.title,
|
||||||
address: dto.address,
|
// address: dto.address,
|
||||||
city: dto.city,
|
// city: dto.city,
|
||||||
province: dto.province,
|
// province: dto.province,
|
||||||
postalCode: dto.postalCode,
|
// postalCode: dto.postalCode,
|
||||||
latitude: dto.latitude,
|
// latitude: dto.latitude,
|
||||||
longitude: dto.longitude,
|
// longitude: dto.longitude,
|
||||||
phone: dto.phone,
|
// phone: dto.phone,
|
||||||
isDefault: dto.isDefault ?? false,
|
// isDefault: dto.isDefault ?? false,
|
||||||
});
|
// });
|
||||||
|
|
||||||
await this.em.persistAndFlush(address);
|
// await this.em.persistAndFlush(address);
|
||||||
return address;
|
// return address;
|
||||||
}
|
// }
|
||||||
|
|
||||||
async getUserAddress(userId: string, addressId: string): Promise<UserAddress> {
|
// async getUserAddress(userId: string, addressId: string): Promise<UserAddress> {
|
||||||
const address = await this.em.findOne(UserAddress, {
|
// const address = await this.em.findOne(UserAddress, {
|
||||||
id: addressId,
|
// id: addressId,
|
||||||
user: { id: userId },
|
// user: { id: userId },
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (!address) {
|
// if (!address) {
|
||||||
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
// throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
return address;
|
// return address;
|
||||||
}
|
// }
|
||||||
|
|
||||||
async updateUserAddress(userId: string, addressId: string, dto: UpdateUserAddressDto): Promise<UserAddress> {
|
// async updateUserAddress(userId: string, addressId: string, dto: UpdateUserAddressDto): Promise<UserAddress> {
|
||||||
const address = await this.em.findOne(UserAddress, {
|
// const address = await this.em.findOne(UserAddress, {
|
||||||
id: addressId,
|
// id: addressId,
|
||||||
user: { id: userId },
|
// user: { id: userId },
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (!address) {
|
// if (!address) {
|
||||||
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
// throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// If setting as default, unset other default addresses
|
// // If setting as default, unset other default addresses
|
||||||
if (dto.isDefault === true) {
|
// if (dto.isDefault === true) {
|
||||||
const existingAddresses = await this.em.find(UserAddress, {
|
// const existingAddresses = await this.em.find(UserAddress, {
|
||||||
user: { id: userId },
|
// user: { id: userId },
|
||||||
isDefault: true,
|
// isDefault: true,
|
||||||
id: { $ne: addressId },
|
// id: { $ne: addressId },
|
||||||
});
|
// });
|
||||||
for (const existingAddress of existingAddresses) {
|
// for (const existingAddress of existingAddresses) {
|
||||||
existingAddress.isDefault = false;
|
// existingAddress.isDefault = false;
|
||||||
}
|
// }
|
||||||
await this.em.flush();
|
// await this.em.flush();
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Update address fields
|
// // Update address fields
|
||||||
this.em.assign(address, dto);
|
// this.em.assign(address, dto);
|
||||||
await this.em.flush();
|
// await this.em.flush();
|
||||||
|
|
||||||
return address;
|
// return address;
|
||||||
}
|
// }
|
||||||
|
|
||||||
async deleteUserAddress(userId: string, addressId: string): Promise<void> {
|
// async deleteUserAddress(userId: string, addressId: string): Promise<void> {
|
||||||
const address = await this.em.findOne(UserAddress, {
|
// const address = await this.em.findOne(UserAddress, {
|
||||||
id: addressId,
|
// id: addressId,
|
||||||
user: { id: userId },
|
// user: { id: userId },
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (!address) {
|
// if (!address) {
|
||||||
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
// throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Soft delete the address
|
// // Soft delete the address
|
||||||
address.deletedAt = new Date();
|
// address.deletedAt = new Date();
|
||||||
await this.em.persistAndFlush(address);
|
// await this.em.persistAndFlush(address);
|
||||||
}
|
// }
|
||||||
|
|
||||||
async setDefaultAddress(userId: string, addressId: string): Promise<UserAddress> {
|
// async setDefaultAddress(userId: string, addressId: string): Promise<UserAddress> {
|
||||||
const address = await this.em.findOne(UserAddress, {
|
// const address = await this.em.findOne(UserAddress, {
|
||||||
id: addressId,
|
// id: addressId,
|
||||||
user: { id: userId },
|
// user: { id: userId },
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (!address) {
|
// if (!address) {
|
||||||
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
// throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Unset all other default addresses
|
// // Unset all other default addresses
|
||||||
const existingAddresses = await this.em.find(UserAddress, {
|
// const existingAddresses = await this.em.find(UserAddress, {
|
||||||
user: { id: userId },
|
// user: { id: userId },
|
||||||
isDefault: true,
|
// isDefault: true,
|
||||||
id: { $ne: addressId },
|
// id: { $ne: addressId },
|
||||||
});
|
// });
|
||||||
for (const existingAddress of existingAddresses) {
|
// for (const existingAddress of existingAddresses) {
|
||||||
existingAddress.isDefault = false;
|
// existingAddress.isDefault = false;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Set this address as default
|
// // Set this address as default
|
||||||
address.isDefault = true;
|
// address.isDefault = true;
|
||||||
await this.em.flush();
|
// await this.em.flush();
|
||||||
|
|
||||||
return address;
|
// return address;
|
||||||
}
|
// }
|
||||||
|
|
||||||
async convertScoreToWallet(userId: string, restId: string) {
|
|
||||||
return this.em.transactional(async em => {
|
|
||||||
const user = await em.findOne(User, { id: userId });
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException(`User with ID ${userId} not found.`);
|
|
||||||
}
|
|
||||||
const restaurant = await em.findOne(Restaurant, { id: restId });
|
|
||||||
if (!restaurant) {
|
|
||||||
throw new NotFoundException(`Restaurant with ID ${restId} not found.`);
|
|
||||||
}
|
|
||||||
let walletTransaction = await em.findOne(WalletTransaction, { user: { id: userId }, restaurant: { id: restId } },
|
|
||||||
{ orderBy: { createdAt: 'DESC' } });
|
|
||||||
|
|
||||||
if (!walletTransaction) {
|
|
||||||
walletTransaction = em.create(WalletTransaction,
|
|
||||||
{
|
|
||||||
user: user,
|
|
||||||
restaurant,
|
|
||||||
balance: 0,
|
|
||||||
amount: 0,
|
|
||||||
type: WalletTransactionType.CREDIT,
|
|
||||||
reason: WalletTransactionReason.DEPOSIT
|
|
||||||
});
|
|
||||||
await em.persistAndFlush(walletTransaction);
|
|
||||||
}
|
|
||||||
// Determine how many points to convert
|
|
||||||
const pointsToConvert = walletTransaction.balance;
|
|
||||||
|
|
||||||
// Validate points to convert
|
|
||||||
if (pointsToConvert <= 0) {
|
|
||||||
throw new BadRequestException('Points to convert must be greater than 0.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!restaurant.score) {
|
|
||||||
throw new BadRequestException('Restaurant score not found.');
|
|
||||||
}
|
|
||||||
if (!restaurant.score.purchaseScore) {
|
|
||||||
throw new BadRequestException('Restaurant purchase score not found.');
|
|
||||||
}
|
|
||||||
if (!restaurant.score.purchaseAmount) {
|
|
||||||
throw new BadRequestException('Restaurant purchase amount not found.');
|
|
||||||
}
|
|
||||||
const walletIncreaseAmount =
|
|
||||||
(Number(pointsToConvert) * Number(restaurant.score.purchaseAmount)) / Number(restaurant.score.purchaseScore);
|
|
||||||
// Convert points to wallet (1:1 ratio - can be made configurable)
|
|
||||||
|
|
||||||
const newWalletTransaction = em.create(WalletTransaction, {
|
|
||||||
user: user,
|
|
||||||
restaurant: restaurant,
|
|
||||||
amount: walletIncreaseAmount,
|
|
||||||
type: WalletTransactionType.CREDIT,
|
|
||||||
reason: WalletTransactionReason.CONVERT_SCORE_TO_WALLET,
|
|
||||||
balance: walletTransaction.balance + walletIncreaseAmount,
|
|
||||||
});
|
|
||||||
// Update user's wallet and points
|
|
||||||
walletTransaction.balance = walletTransaction.balance + walletIncreaseAmount;
|
|
||||||
em.persist([newWalletTransaction]);
|
|
||||||
|
|
||||||
await em.flush();
|
|
||||||
|
|
||||||
return user;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getUserWallet(userId: string, restId: string): Promise<WalletTransaction | null> {
|
|
||||||
return this.walletTransactionRepository.findOne({ user: { id: userId }, restaurant: { id: restId } },
|
|
||||||
{ orderBy: { createdAt: 'DESC' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
getUserWalletTransactions(userId: string, restId: string, dto: FindWalletTransactionsDto) {
|
|
||||||
return this.walletService.getUserWalletTransactions(userId, restId, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createWalletTransaction(userId: string, restId: string, dto: CreateWalletTransactionDto) {
|
|
||||||
return this.em.transactional(async em => {
|
|
||||||
return this.walletService.createTransaction(em, userId, restId, dto);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,183 +1,180 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { FilterQuery } from '@mikro-orm/core';
|
import { FilterQuery } from '@mikro-orm/core';
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { WalletTransaction } from '../entities/wallet-transaction.entity';
|
import { CreditTransactionRepository } from '../repositories/credit-transaction.repository';
|
||||||
import { WalletTransactionRepository } from '../repositories/wallet-transaction.repository';
|
|
||||||
import { FindWalletTransactionsDto } from '../dto/find-wallet-transactions.dto';
|
import { FindWalletTransactionsDto } from '../dto/find-wallet-transactions.dto';
|
||||||
import { CreateWalletTransactionDto } from '../dto/create-wallet-transaction.dto';
|
|
||||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
import { WalletTransactionType } from '../interface/wallet';
|
import { CreditTransaction } from '../entities/credit-transaction.entity';
|
||||||
import { PointTransactionRepository } from '../repositories/point-transaction.repository';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WalletService {
|
export class WalletService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly walletTransactionRepository: WalletTransactionRepository,
|
private readonly walletTransactionRepository: CreditTransactionRepository,
|
||||||
private readonly pointTransactionRepository: PointTransactionRepository,
|
private readonly pointTransactionRepository: CreditTransaction,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async getUserWalletTransactions(
|
// async getUserWalletTransactions(
|
||||||
userId: string,
|
// userId: string,
|
||||||
restId: string,
|
// dto: FindWalletTransactionsDto,
|
||||||
dto: FindWalletTransactionsDto,
|
// ): Promise<PaginatedResult<CreditTransaction>> {
|
||||||
): Promise<PaginatedResult<WalletTransaction>> {
|
// const {
|
||||||
const {
|
// page = 1,
|
||||||
page = 1,
|
// limit = 10,
|
||||||
limit = 10,
|
// type,
|
||||||
type,
|
// reason,
|
||||||
reason,
|
// startDate,
|
||||||
startDate,
|
// endDate,
|
||||||
endDate,
|
// minAmount,
|
||||||
minAmount,
|
// maxAmount,
|
||||||
maxAmount,
|
// orderBy = 'createdAt',
|
||||||
orderBy = 'createdAt',
|
// order = 'desc',
|
||||||
order = 'desc',
|
// } = dto;
|
||||||
} = dto;
|
|
||||||
|
|
||||||
// Calculate pagination
|
// // Calculate pagination
|
||||||
const offset = (page - 1) * limit;
|
// const offset = (page - 1) * limit;
|
||||||
|
|
||||||
// Find the user's wallet for this restaurant
|
// // Find the user's wallet for this restaurant
|
||||||
const walletTransaction = await this.walletTransactionRepository.findOne({
|
// const walletTransaction = await this.walletTransactionRepository.findOne({
|
||||||
user: { id: userId },
|
// user: { id: userId },
|
||||||
restaurant: { id: restId },
|
// restaurant: { id: },
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (!walletTransaction) {
|
// if (!walletTransaction) {
|
||||||
throw new NotFoundException(`User wallet not found for user ${userId} and restaurant ${restId}.`);
|
// throw new NotFoundException(`User wallet not found for user ${userId} and restaurant ${}.`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Build the 'where' filter query
|
// // Build the 'where' filter query
|
||||||
const where: FilterQuery<WalletTransaction> = {
|
// const where: FilterQuery<WalletTransaction> = {
|
||||||
user: { id: userId },
|
// user: { id: userId },
|
||||||
restaurant: { id: restId },
|
// restaurant: { id: },
|
||||||
};
|
// };
|
||||||
|
|
||||||
// Add type filter
|
// // Add type filter
|
||||||
if (type) {
|
// if (type) {
|
||||||
where.type = type;
|
// where.type = type;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Add reason filter
|
// // Add reason filter
|
||||||
if (reason) {
|
// if (reason) {
|
||||||
where.reason = reason;
|
// where.reason = reason;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Add date range filters
|
// // Add date range filters
|
||||||
if (startDate || endDate) {
|
// if (startDate || endDate) {
|
||||||
where.createdAt = {};
|
// where.createdAt = {};
|
||||||
if (startDate) {
|
// if (startDate) {
|
||||||
where.createdAt.$gte = new Date(startDate);
|
// where.createdAt.$gte = new Date(startDate);
|
||||||
}
|
// }
|
||||||
if (endDate) {
|
// if (endDate) {
|
||||||
where.createdAt.$lte = new Date(endDate);
|
// where.createdAt.$lte = new Date(endDate);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Add amount range filters
|
// // Add amount range filters
|
||||||
if (minAmount !== undefined || maxAmount !== undefined) {
|
// if (minAmount !== undefined || maxAmount !== undefined) {
|
||||||
where.amount = {};
|
// where.amount = {};
|
||||||
if (minAmount !== undefined) {
|
// if (minAmount !== undefined) {
|
||||||
where.amount.$gte = minAmount;
|
// where.amount.$gte = minAmount;
|
||||||
}
|
// }
|
||||||
if (maxAmount !== undefined) {
|
// if (maxAmount !== undefined) {
|
||||||
where.amount.$lte = maxAmount;
|
// where.amount.$lte = maxAmount;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Execute the query using findAndCount
|
// // Execute the query using findAndCount
|
||||||
const [transactions, total] = await this.walletTransactionRepository.findAndCount(where, {
|
// const [transactions, total] = await this.walletTransactionRepository.findAndCount(where, {
|
||||||
limit,
|
// limit,
|
||||||
offset,
|
// offset,
|
||||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
// orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||||
});
|
// });
|
||||||
|
|
||||||
// Calculate total pages
|
// // Calculate total pages
|
||||||
const totalPages = Math.ceil(total / limit);
|
// const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
// Return the paginated result
|
// // Return the paginated result
|
||||||
return {
|
// return {
|
||||||
data: transactions,
|
// data: transactions,
|
||||||
meta: {
|
// meta: {
|
||||||
total,
|
// total,
|
||||||
page,
|
// page,
|
||||||
limit,
|
// limit,
|
||||||
totalPages,
|
// totalPages,
|
||||||
},
|
// },
|
||||||
};
|
// };
|
||||||
}
|
// }
|
||||||
|
|
||||||
getUserWallet(userId: string, restId: string): Promise<WalletTransaction | null> {
|
// getUserWallet(userId: string, : string): Promise<WalletTransaction | null> {
|
||||||
return this.walletTransactionRepository.findOne(
|
// return this.walletTransactionRepository.findOne(
|
||||||
{
|
// {
|
||||||
user: { id: userId },
|
// user: { id: userId },
|
||||||
restaurant: { id: restId },
|
// restaurant: { id: },
|
||||||
},
|
// },
|
||||||
{ orderBy: { createdAt: 'DESC' } },
|
// { orderBy: { createdAt: 'DESC' } },
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
async createTransaction(
|
// async createTransaction(
|
||||||
em: EntityManager,
|
// em: EntityManager,
|
||||||
userId: string,
|
// userId: string,
|
||||||
restId: string,
|
// : string,
|
||||||
dto: CreateWalletTransactionDto,
|
// dto: CreateWalletTransactionDto,
|
||||||
): Promise<WalletTransaction> {
|
// ): Promise<WalletTransaction> {
|
||||||
const { amount, type, reason } = dto;
|
// const { amount, type, reason } = dto;
|
||||||
|
|
||||||
// Validate amount
|
// // Validate amount
|
||||||
if (amount <= 0) {
|
// if (amount <= 0) {
|
||||||
throw new BadRequestException('Transaction amount must be greater than 0.');
|
// throw new BadRequestException('Transaction amount must be greater than 0.');
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Find the user's wallet for this restaurant
|
// // Find the user's wallet for this restaurant
|
||||||
const walletTransaction = await em.findOne(WalletTransaction, {
|
// const walletTransaction = await em.findOne(WalletTransaction, {
|
||||||
user: { id: userId },
|
// user: { id: userId },
|
||||||
restaurant: { id: restId },
|
// restaurant: { id: },
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (!walletTransaction) {
|
// if (!walletTransaction) {
|
||||||
throw new NotFoundException(`User wallet not found for user ${userId} and restaurant ${restId}.`);
|
// throw new NotFoundException(`User wallet not found for user ${userId} and restaurant ${}.`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Calculate new balance
|
// // Calculate new balance
|
||||||
const currentBalance = walletTransaction.balance || 0;
|
// const currentBalance = walletTransaction.balance || 0;
|
||||||
let newBalance: number;
|
// let newBalance: number;
|
||||||
|
|
||||||
if (type === WalletTransactionType.CREDIT) {
|
// if (type === WalletTransactionType.CREDIT) {
|
||||||
newBalance = currentBalance + amount;
|
// newBalance = currentBalance + amount;
|
||||||
} else if (type === WalletTransactionType.DEBIT) {
|
// } else if (type === WalletTransactionType.DEBIT) {
|
||||||
if (currentBalance < amount) {
|
// if (currentBalance < amount) {
|
||||||
throw new BadRequestException(`Insufficient wallet balance. Current: ${currentBalance}, Required: ${amount}.`);
|
// throw new BadRequestException(`Insufficient wallet balance. Current: ${currentBalance}, Required: ${amount}.`);
|
||||||
}
|
// }
|
||||||
newBalance = currentBalance - amount;
|
// newBalance = currentBalance - amount;
|
||||||
} else {
|
// } else {
|
||||||
throw new BadRequestException(`Invalid transaction type: ${type}`);
|
// throw new BadRequestException(`Invalid transaction type: ${type}`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Update wallet balance
|
// // Update wallet balance
|
||||||
walletTransaction.balance = newBalance;
|
// walletTransaction.balance = newBalance;
|
||||||
await em.persistAndFlush(walletTransaction);
|
// await em.persistAndFlush(walletTransaction);
|
||||||
|
|
||||||
// Create transaction record
|
// // Create transaction record
|
||||||
const transaction = em.create(WalletTransaction, {
|
// const transaction = em.create(WalletTransaction, {
|
||||||
user: walletTransaction.user,
|
// user: walletTransaction.user,
|
||||||
restaurant: walletTransaction.restaurant,
|
// restaurant: walletTransaction.restaurant,
|
||||||
amount,
|
// amount,
|
||||||
type,
|
// type,
|
||||||
reason,
|
// reason,
|
||||||
balance: newBalance,
|
// balance: newBalance,
|
||||||
});
|
// });
|
||||||
await em.persistAndFlush([walletTransaction, transaction]);
|
// await em.persistAndFlush([walletTransaction, transaction]);
|
||||||
return transaction;
|
// return transaction;
|
||||||
}
|
// }
|
||||||
|
|
||||||
async getUserCurrentWalletBalance(userId: string, restuarantId: string) {
|
// async getUserCurrentWalletBalance(userId: string, restuarantId: string) {
|
||||||
const balance = await this.walletTransactionRepository.getCurrentWalletBalance(userId, restuarantId);
|
// const balance = await this.walletTransactionRepository.getCurrentWalletBalance(userId, restuarantId);
|
||||||
return { balance };
|
// return { balance };
|
||||||
}
|
// }
|
||||||
async getUserCurrentPoinrBalance(userId: string, restuarantId: string) {
|
// async getUserCurrentPoinrBalance(userId: string, restuarantId: string) {
|
||||||
const balance = await this.pointTransactionRepository.getcurrentPointBalance(userId, restuarantId);
|
// const balance = await this.pointTransactionRepository.getcurrentPointBalance(userId, restuarantId);
|
||||||
return { balance };
|
// return { balance };
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||||
|
import { CreditTransaction } from '../entities/credit-transaction.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CreditTransactionRepository extends EntityRepository<CreditTransaction> {
|
||||||
|
constructor(readonly em: EntityManager) {
|
||||||
|
super(em, CreditTransaction);
|
||||||
|
}
|
||||||
|
async getCurrentCredit(userId: string,): Promise<number> {
|
||||||
|
const lastRow = await this.em.findOne(
|
||||||
|
CreditTransaction,
|
||||||
|
{
|
||||||
|
user: { id: userId },
|
||||||
|
},
|
||||||
|
{ orderBy: { createdAt: 'desc' } },
|
||||||
|
);
|
||||||
|
return lastRow?.balance || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
|
||||||
import { PointTransaction } from '../entities/point-transaction.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PointTransactionRepository extends EntityRepository<PointTransaction> {
|
|
||||||
constructor(readonly em: EntityManager) {
|
|
||||||
super(em, PointTransaction);
|
|
||||||
}
|
|
||||||
async getcurrentPointBalance(userId: string, restaurantId: string): Promise<number> {
|
|
||||||
const pointTransaction = await this.em.findOne(PointTransaction, {
|
|
||||||
user: { id: userId },
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
});
|
|
||||||
return pointTransaction?.balance || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,59 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
import { EntityManager, EntityRepository, FilterQuery } from '@mikro-orm/postgresql';
|
||||||
import { User } from '../entities/user.entity';
|
import { User } from '../entities/user.entity';
|
||||||
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
|
import { FindUsersDto } from '../dto/find-user.dto';
|
||||||
|
import { normalizePhone } from 'src/modules/util/phone.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserRepository extends EntityRepository<User> {
|
export class UserRepository extends EntityRepository<User> {
|
||||||
constructor(readonly em: EntityManager) {
|
constructor(readonly em: EntityManager) {
|
||||||
super(em, User);
|
super(em, User);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAllPaginated(dto: FindUsersDto): Promise<PaginatedResult<User>> {
|
||||||
|
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc' } = dto;
|
||||||
|
|
||||||
|
// 1. Calculate pagination
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// 2. Build the 'where' filter query
|
||||||
|
const where: FilterQuery<User> = {};
|
||||||
|
|
||||||
|
// 4. Add 'search' logic (case-insensitive)
|
||||||
|
if (search) {
|
||||||
|
const searchPattern = `%${search}%`;
|
||||||
|
const normalizedSearch = normalizePhone(search);
|
||||||
|
const normalizedSearchPattern = `%${normalizedSearch}%`;
|
||||||
|
// $ilike is case-insensitive (PostgreSQL). Use $like for MySQL (often CI by default)
|
||||||
|
// Search with both original and normalized phone patterns to handle various input formats
|
||||||
|
where.$or = [
|
||||||
|
{ firstName: { $ilike: searchPattern } },
|
||||||
|
{ lastName: { $ilike: searchPattern } },
|
||||||
|
{ phone: { $ilike: searchPattern } },
|
||||||
|
...(normalizedSearch !== search ? [{ phone: { $ilike: normalizedSearchPattern } }] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Execute the query using findAndCount
|
||||||
|
const [users, total] = await this.findAndCount(where, {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Calculate total pages
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
// 7. Return the paginated result
|
||||||
|
return {
|
||||||
|
data: users,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
|
||||||
import { WalletTransaction } from '../entities/wallet-transaction.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class WalletTransactionRepository extends EntityRepository<WalletTransaction> {
|
|
||||||
constructor(readonly em: EntityManager) {
|
|
||||||
super(em, WalletTransaction);
|
|
||||||
}
|
|
||||||
async getCurrentWalletBalance(userId: string, restaurantId: string): Promise<number> {
|
|
||||||
const walletTransaction = await this.em.findOne(
|
|
||||||
WalletTransaction,
|
|
||||||
{
|
|
||||||
user: { id: userId },
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
},
|
|
||||||
{ orderBy: { createdAt: 'desc' } },
|
|
||||||
);
|
|
||||||
return walletTransaction?.balance || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,21 +6,17 @@ import { User } from './entities/user.entity';
|
|||||||
import { UserAddress } from './entities/user-address.entity';
|
import { UserAddress } from './entities/user-address.entity';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { UserRepository } from './repositories/user.repository';
|
import { UserRepository } from './repositories/user.repository';
|
||||||
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
import { CreditTransactionRepository } from './repositories/credit-transaction.repository';
|
||||||
import { WalletTransactionRepository } from './repositories/wallet-transaction.repository';
|
|
||||||
import { WalletService } from './providers/wallet.service';
|
import { WalletService } from './providers/wallet.service';
|
||||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
import { CreditTransaction } from './entities/credit-transaction.entity';
|
||||||
import { PointTransaction } from './entities/point-transaction.entity';
|
|
||||||
import { PointTransactionRepository } from './repositories/point-transaction.repository';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [UserService, WalletService, UserRepository, WalletTransactionRepository, PointTransactionRepository],
|
providers: [UserService, WalletService, UserRepository, CreditTransactionRepository, CreditTransactionRepository],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
imports: [
|
imports: [
|
||||||
MikroOrmModule.forFeature([User, UserAddress, WalletTransaction, PointTransaction]),
|
MikroOrmModule.forFeature([User, UserAddress, CreditTransaction]),
|
||||||
JwtModule,
|
JwtModule,
|
||||||
RestaurantsModule,
|
|
||||||
],
|
],
|
||||||
exports: [UserService, WalletService, UserRepository, WalletTransactionRepository, PointTransactionRepository],
|
exports: [UserService, WalletService, UserRepository, CreditTransactionRepository, CreditTransactionRepository],
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule { }
|
||||||
|
|||||||
@@ -13,26 +13,26 @@ export class SchedulesSeeder {
|
|||||||
// Create 3 schedules per day
|
// Create 3 schedules per day
|
||||||
for (const timeSlot of timeSlots) {
|
for (const timeSlot of timeSlots) {
|
||||||
const existing = await em.findOne(Schedule, {
|
const existing = await em.findOne(Schedule, {
|
||||||
restId: restaurant.id,
|
: restaurant.id,
|
||||||
weekDay,
|
weekDay,
|
||||||
openTime: timeSlot.openTime,
|
openTime: timeSlot.openTime,
|
||||||
closeTime: timeSlot.closeTime,
|
closeTime: timeSlot.closeTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const schedule = em.create(Schedule, {
|
const schedule = em.create(Schedule, {
|
||||||
restId: restaurant.id,
|
: restaurant.id,
|
||||||
weekDay,
|
weekDay,
|
||||||
openTime: timeSlot.openTime,
|
openTime: timeSlot.openTime,
|
||||||
closeTime: timeSlot.closeTime,
|
closeTime: timeSlot.closeTime,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
em.persist(schedule);
|
em.persist(schedule);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await em.flush();
|
}
|
||||||
|
|
||||||
|
await em.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,561 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Notifications Socket.IO Test</title>
|
|
||||||
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 30px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 28px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header p {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 4px solid #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section h2 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
color: #555;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
border: 2px solid #e0e0e0;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus,
|
|
||||||
select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 12px 24px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: #5568d3;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success:hover {
|
|
||||||
background: #218838;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: #c82333;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #5a6268;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.connected {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.disconnected {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.connecting {
|
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
border: 1px solid #ffeaa7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.events {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: #1e1e1e;
|
|
||||||
color: #d4d4d4;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-item {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
background: #2d2d2d;
|
|
||||||
border-radius: 4px;
|
|
||||||
border-left: 3px solid #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-time {
|
|
||||||
color: #858585;
|
|
||||||
font-size: 11px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-type {
|
|
||||||
color: #4ec9b0;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-data {
|
|
||||||
color: #ce9178;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notifications-list {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 15px;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item {
|
|
||||||
background: white;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border-left: 3px solid #667eea;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-content {
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>🔔 Notifications Socket.IO Test Client</h1>
|
|
||||||
<p>Test real-time admin notifications</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<!-- Connection Section -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Connection</h2>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="serverUrl">Server URL:</label>
|
|
||||||
<input type="text" id="serverUrl" value="https://dmenuplus-api.dev.danakcorp.com"
|
|
||||||
placeholder="https://dmenuplus-api.dev.danakcorp.com" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="authToken">Admin Token:</label>
|
|
||||||
<input type="text" id="authToken" placeholder="Bearer token (required)" />
|
|
||||||
<small style="color: #666; display: block; margin-top: 5px"
|
|
||||||
>Enter JWT token for admin authentication. Restaurant ID will be extracted from token.</small
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="button-group">
|
|
||||||
<button class="btn-primary" onclick="connect()">Connect</button>
|
|
||||||
<button class="btn-danger" onclick="disconnect()">Disconnect</button>
|
|
||||||
</div>
|
|
||||||
<div id="connectionStatus" class="status disconnected">Status: Disconnected</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notifications Section -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Get Notifications</h2>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="limit">Limit (optional):</label>
|
|
||||||
<input type="number" id="limit" placeholder="50" value="50" min="1" max="100" />
|
|
||||||
<small style="color: #666; display: block; margin-top: 5px"
|
|
||||||
>Number of notifications to retrieve (default: 50)</small
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="button-group">
|
|
||||||
<button class="btn-success" onclick="getNotifications()">Get Notifications</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notifications List Section -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Notifications List</h2>
|
|
||||||
<div id="notificationsList" class="notifications-list">
|
|
||||||
<div style="text-align: center; color: #999; padding: 20px">
|
|
||||||
No notifications loaded yet. Click "Get Notifications" to fetch.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Events Section -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Events Log <button class="btn-secondary clear-btn" onclick="clearEvents()">Clear</button></h2>
|
|
||||||
<div id="events" class="events">
|
|
||||||
<div class="event-item">
|
|
||||||
<div class="event-time">Ready to connect...</div>
|
|
||||||
<div class="event-type">INFO</div>
|
|
||||||
<div class="event-data">Enter server URL and admin token, then click Connect to start</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let socket = null;
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
const serverUrl = document.getElementById('serverUrl').value || 'http://localhost:4000';
|
|
||||||
const token = document.getElementById('authToken').value.trim();
|
|
||||||
|
|
||||||
if (socket && socket.connected) {
|
|
||||||
addEvent('WARNING', 'Already connected!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
addEvent('ERROR', 'Admin token is required!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatus('connecting', 'Connecting...');
|
|
||||||
addEvent('INFO', `Connecting to ${serverUrl}/notifications...`);
|
|
||||||
|
|
||||||
const authOptions = {};
|
|
||||||
if (token) {
|
|
||||||
// Remove "Bearer " prefix if present
|
|
||||||
const cleanToken = token.startsWith('Bearer ') ? token.substring(7) : token;
|
|
||||||
authOptions.token = cleanToken;
|
|
||||||
addEvent('INFO', 'Using authentication token');
|
|
||||||
}
|
|
||||||
|
|
||||||
socket = io(`${serverUrl}/notifications`, {
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
reconnection: true,
|
|
||||||
reconnectionDelay: 1000,
|
|
||||||
reconnectionAttempts: 5,
|
|
||||||
auth: authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
updateStatus('connected', 'Connected');
|
|
||||||
addEvent('SUCCESS', `Connected! Socket ID: ${socket.id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('disconnect', reason => {
|
|
||||||
updateStatus('disconnected', 'Disconnected');
|
|
||||||
addEvent('WARNING', `Disconnected: ${reason}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect_error', error => {
|
|
||||||
updateStatus('disconnected', 'Connection Failed');
|
|
||||||
addEvent('ERROR', `Connection error: ${error.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('joined', data => {
|
|
||||||
addEvent('SUCCESS', `Joined room: ${data.room || 'unknown'}`, data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle notifications event - can be either:
|
|
||||||
// 1. List response: { notifications: [...] }
|
|
||||||
// 2. Real-time notification: { subject: string, body: string }
|
|
||||||
socket.on('notifications', data => {
|
|
||||||
if (Array.isArray(data.notifications)) {
|
|
||||||
// List of notifications response
|
|
||||||
addEvent('SUCCESS', `Received ${data.notifications.length} notifications`, data);
|
|
||||||
displayNotifications(data.notifications);
|
|
||||||
} else if (data.subject || data.body) {
|
|
||||||
// Real-time single notification
|
|
||||||
addEvent('NOTIFICATION', 'New notification received', data);
|
|
||||||
addRealTimeNotification(data);
|
|
||||||
} else if (Array.isArray(data)) {
|
|
||||||
// Direct array response
|
|
||||||
addEvent('SUCCESS', `Received ${data.length} notifications`, data);
|
|
||||||
displayNotifications(data);
|
|
||||||
} else {
|
|
||||||
// Unknown format, try to extract notifications from various possible structures
|
|
||||||
addEvent('INFO', 'Received notifications data', data);
|
|
||||||
const notifications = data.notifications || data.data || (Array.isArray(data) ? data : [data]);
|
|
||||||
if (Array.isArray(notifications) && notifications.length > 0) {
|
|
||||||
displayNotifications(notifications);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', data => {
|
|
||||||
addEvent('ERROR', data.message || 'Error occurred', data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle authentication errors
|
|
||||||
socket.on('exception', data => {
|
|
||||||
addEvent('ERROR', `Server error: ${data.message || JSON.stringify(data)}`, data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnect() {
|
|
||||||
if (socket) {
|
|
||||||
socket.disconnect();
|
|
||||||
socket = null;
|
|
||||||
updateStatus('disconnected', 'Disconnected');
|
|
||||||
addEvent('INFO', 'Manually disconnected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNotifications() {
|
|
||||||
if (!socket || !socket.connected) {
|
|
||||||
addEvent('ERROR', 'Please connect first!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const limitInput = document.getElementById('limit');
|
|
||||||
const limit = limitInput.value ? parseInt(limitInput.value, 10) : 50;
|
|
||||||
|
|
||||||
if (limit < 1 || limit > 100) {
|
|
||||||
addEvent('ERROR', 'Limit must be between 1 and 100');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit('get:notifications', { limit });
|
|
||||||
addEvent('INFO', `Requesting notifications (limit: ${limit})...`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayNotifications(notifications) {
|
|
||||||
const listDiv = document.getElementById('notificationsList');
|
|
||||||
|
|
||||||
if (!notifications || notifications.length === 0) {
|
|
||||||
listDiv.innerHTML =
|
|
||||||
'<div style="text-align: center; color: #999; padding: 20px;">No notifications found.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
notifications.forEach(notification => {
|
|
||||||
// Handle both full notification objects and simple payloads
|
|
||||||
const title = notification.title || notification.subject || 'N/A';
|
|
||||||
const content = notification.content || notification.body || 'No content';
|
|
||||||
const id = notification.id || 'N/A';
|
|
||||||
const date = notification.createdAt
|
|
||||||
? new Date(notification.createdAt).toLocaleString()
|
|
||||||
: notification.created_at
|
|
||||||
? new Date(notification.created_at).toLocaleString()
|
|
||||||
: 'N/A';
|
|
||||||
|
|
||||||
const userName = notification.user
|
|
||||||
? `${notification.user.firstName || ''} ${notification.user.lastName || ''}`.trim()
|
|
||||||
: notification.userName || '';
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="notification-item">
|
|
||||||
<div class="notification-title">${escapeHtml(title)}</div>
|
|
||||||
<div class="notification-content">${escapeHtml(content)}</div>
|
|
||||||
<div class="notification-meta">
|
|
||||||
ID: ${id} | Created: ${date}
|
|
||||||
${userName ? ` | User: ${escapeHtml(userName)}` : ''}
|
|
||||||
${notification.admin ? ` | Admin: ${notification.admin.firstName || ''} ${notification.admin.lastName || ''}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
listDiv.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRealTimeNotification(notification) {
|
|
||||||
const listDiv = document.getElementById('notificationsList');
|
|
||||||
const title = notification.subject || notification.title || 'New Notification';
|
|
||||||
const content = notification.body || notification.content || 'No content';
|
|
||||||
const currentTime = new Date().toLocaleString();
|
|
||||||
|
|
||||||
const notificationHtml = `
|
|
||||||
<div class="notification-item" style="animation: slideIn 0.3s ease-out;">
|
|
||||||
<div class="notification-title">${escapeHtml(title)}</div>
|
|
||||||
<div class="notification-content">${escapeHtml(content)}</div>
|
|
||||||
<div class="notification-meta">
|
|
||||||
Received: ${currentTime}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add to the top of the list
|
|
||||||
if (listDiv.children.length === 1 && listDiv.children[0].textContent.includes('No notifications')) {
|
|
||||||
listDiv.innerHTML = notificationHtml;
|
|
||||||
} else {
|
|
||||||
listDiv.insertAdjacentHTML('afterbegin', notificationHtml);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatus(status, message) {
|
|
||||||
const statusEl = document.getElementById('connectionStatus');
|
|
||||||
statusEl.className = `status ${status}`;
|
|
||||||
statusEl.textContent = `Status: ${message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addEvent(type, message, data = null) {
|
|
||||||
const eventsDiv = document.getElementById('events');
|
|
||||||
const eventItem = document.createElement('div');
|
|
||||||
eventItem.className = 'event-item';
|
|
||||||
|
|
||||||
const time = new Date().toLocaleTimeString();
|
|
||||||
const typeColors = {
|
|
||||||
INFO: '#4ec9b0',
|
|
||||||
SUCCESS: '#4ec9b0',
|
|
||||||
WARNING: '#dcdcaa',
|
|
||||||
ERROR: '#f48771',
|
|
||||||
NOTIFICATIONS_LIST: '#4fc1ff',
|
|
||||||
NOTIFICATION: '#9cdcfe',
|
|
||||||
};
|
|
||||||
|
|
||||||
eventItem.innerHTML = `
|
|
||||||
<div class="event-time">${time}</div>
|
|
||||||
<div class="event-type" style="color: ${typeColors[type] || '#4ec9b0'}">[${type}]</div>
|
|
||||||
<div class="event-data">${message}</div>
|
|
||||||
${data ? `<div class="event-data" style="margin-top: 8px; color: #9cdcfe;">${JSON.stringify(data, null, 2)}</div>` : ''}
|
|
||||||
`;
|
|
||||||
|
|
||||||
eventsDiv.insertBefore(eventItem, eventsDiv.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearEvents() {
|
|
||||||
document.getElementById('events').innerHTML = '';
|
|
||||||
addEvent('INFO', 'Events log cleared');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,494 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Pager Socket.IO Test</title>
|
|
||||||
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 30px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 28px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header p {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 4px solid #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section h2 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
color: #555;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
border: 2px solid #e0e0e0;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus, select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 12px 24px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: #5568d3;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success:hover {
|
|
||||||
background: #218838;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: #c82333;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #5a6268;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.connected {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.disconnected {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.connecting {
|
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
border: 1px solid #ffeaa7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.events {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: #1e1e1e;
|
|
||||||
color: #d4d4d4;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-item {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
background: #2d2d2d;
|
|
||||||
border-radius: 4px;
|
|
||||||
border-left: 3px solid #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-time {
|
|
||||||
color: #858585;
|
|
||||||
font-size: 11px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-type {
|
|
||||||
color: #4ec9b0;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.event-data {
|
|
||||||
color: #ce9178;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 12px;
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>🔔 Pager Socket.IO Test Client</h1>
|
|
||||||
<p>Test real-time pager notifications</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<!-- Connection Section -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Connection</h2>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="serverUrl">Server URL:</label>
|
|
||||||
<input type="text" id="serverUrl" value="http://localhost:4000" placeholder="http://localhost:4000">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="authToken">Admin Token (for Restaurant rooms):</label>
|
|
||||||
<input type="text" id="authToken" placeholder="Bearer token (optional, required for restaurant rooms)">
|
|
||||||
<small style="color: #666; display: block; margin-top: 5px;">Enter JWT token for admin authentication. Required when joining restaurant rooms.</small>
|
|
||||||
</div>
|
|
||||||
<div class="button-group">
|
|
||||||
<button class="btn-primary" onclick="connect()">Connect</button>
|
|
||||||
<button class="btn-danger" onclick="disconnect()">Disconnect</button>
|
|
||||||
</div>
|
|
||||||
<div id="connectionStatus" class="status disconnected">
|
|
||||||
Status: Disconnected
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Room Management Section -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Room Management</h2>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="userType">User Type:</label>
|
|
||||||
<select id="userType" onchange="updateRoomInputs()">
|
|
||||||
<option value="restaurant">Restaurant (Admin/Staff)</option>
|
|
||||||
<option value="session">Session (Public User)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" id="restaurantGroup">
|
|
||||||
<label for="restId">Restaurant ID:</label>
|
|
||||||
<input type="text" id="restId" placeholder="Auto-extracted from token" disabled>
|
|
||||||
<small style="color: #666; display: block; margin-top: 5px;">Restaurant ID is automatically extracted from your admin token. No need to enter manually.</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" id="sessionGroup" style="display: none;">
|
|
||||||
<label for="cookieId">Cookie ID:</label>
|
|
||||||
<input type="text" id="cookieId" placeholder="Enter cookie ID (from pager cookie)">
|
|
||||||
</div>
|
|
||||||
<div class="button-group">
|
|
||||||
<button class="btn-success" onclick="joinRoom()">Join Room</button>
|
|
||||||
<button class="btn-secondary" onclick="leaveRoom()">Leave Room</button>
|
|
||||||
</div>
|
|
||||||
<div id="roomStatus" style="margin-top: 15px;">
|
|
||||||
<strong>Current Room:</strong> <span id="currentRoom">None</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Events Section -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Events Log <button class="btn-secondary clear-btn" onclick="clearEvents()">Clear</button></h2>
|
|
||||||
<div id="events" class="events">
|
|
||||||
<div class="event-item">
|
|
||||||
<div class="event-time">Ready to connect...</div>
|
|
||||||
<div class="event-type">INFO</div>
|
|
||||||
<div class="event-data">Enter server URL and click Connect to start</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let socket = null;
|
|
||||||
let currentRoom = null;
|
|
||||||
|
|
||||||
function updateRoomInputs() {
|
|
||||||
const userType = document.getElementById('userType').value;
|
|
||||||
const restaurantGroup = document.getElementById('restaurantGroup');
|
|
||||||
const sessionGroup = document.getElementById('sessionGroup');
|
|
||||||
|
|
||||||
if (userType === 'restaurant') {
|
|
||||||
restaurantGroup.style.display = 'block';
|
|
||||||
sessionGroup.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
restaurantGroup.style.display = 'none';
|
|
||||||
sessionGroup.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
const serverUrl = document.getElementById('serverUrl').value || 'http://localhost:4000';
|
|
||||||
const token = document.getElementById('authToken').value.trim();
|
|
||||||
|
|
||||||
if (socket && socket.connected) {
|
|
||||||
addEvent('WARNING', 'Already connected!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatus('connecting', 'Connecting...');
|
|
||||||
addEvent('INFO', `Connecting to ${serverUrl}/pager...`);
|
|
||||||
|
|
||||||
const authOptions = {};
|
|
||||||
if (token) {
|
|
||||||
// Remove "Bearer " prefix if present
|
|
||||||
const cleanToken = token.startsWith('Bearer ') ? token.substring(7) : token;
|
|
||||||
authOptions.token = cleanToken;
|
|
||||||
addEvent('INFO', 'Using authentication token');
|
|
||||||
}
|
|
||||||
|
|
||||||
socket = io(`${serverUrl}/pager`, {
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
reconnection: true,
|
|
||||||
reconnectionDelay: 1000,
|
|
||||||
reconnectionAttempts: 5,
|
|
||||||
auth: authOptions
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
updateStatus('connected', 'Connected');
|
|
||||||
addEvent('SUCCESS', `Connected! Socket ID: ${socket.id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('disconnect', (reason) => {
|
|
||||||
updateStatus('disconnected', 'Disconnected');
|
|
||||||
addEvent('WARNING', `Disconnected: ${reason}`);
|
|
||||||
currentRoom = null;
|
|
||||||
document.getElementById('currentRoom').textContent = 'None';
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect_error', (error) => {
|
|
||||||
updateStatus('disconnected', 'Connection Failed');
|
|
||||||
addEvent('ERROR', `Connection error: ${error.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('joined', (data) => {
|
|
||||||
addEvent('SUCCESS', `Joined room: ${data.room}`, data);
|
|
||||||
currentRoom = data.room;
|
|
||||||
document.getElementById('currentRoom').textContent = data.room;
|
|
||||||
|
|
||||||
// If it's a restaurant room, extract and display the restId
|
|
||||||
if (data.room && data.room.startsWith('restaurant:')) {
|
|
||||||
const restId = data.room.replace('restaurant:', '');
|
|
||||||
document.getElementById('restId').value = restId;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('left', (data) => {
|
|
||||||
addEvent('INFO', `Left room: ${data.room}`, data);
|
|
||||||
if (currentRoom === data.room) {
|
|
||||||
currentRoom = null;
|
|
||||||
document.getElementById('currentRoom').textContent = 'None';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (data) => {
|
|
||||||
addEvent('ERROR', data.message || 'Error occurred', data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pager events
|
|
||||||
socket.on('pager:created', (data) => {
|
|
||||||
addEvent('PAGER_CREATED', 'New pager request created!', data);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('pager:status:updated', (data) => {
|
|
||||||
addEvent('PAGER_STATUS_UPDATED', 'Pager status updated!', data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle authentication errors
|
|
||||||
socket.on('exception', (data) => {
|
|
||||||
addEvent('ERROR', `Server error: ${data.message || JSON.stringify(data)}`, data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnect() {
|
|
||||||
if (socket) {
|
|
||||||
socket.disconnect();
|
|
||||||
socket = null;
|
|
||||||
updateStatus('disconnected', 'Disconnected');
|
|
||||||
addEvent('INFO', 'Manually disconnected');
|
|
||||||
currentRoom = null;
|
|
||||||
document.getElementById('currentRoom').textContent = 'None';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinRoom() {
|
|
||||||
if (!socket || !socket.connected) {
|
|
||||||
addEvent('ERROR', 'Please connect first!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userType = document.getElementById('userType').value;
|
|
||||||
|
|
||||||
if (userType === 'restaurant') {
|
|
||||||
const token = document.getElementById('authToken').value.trim();
|
|
||||||
if (!token) {
|
|
||||||
addEvent('ERROR', 'Admin token is required for restaurant rooms. Please enter your JWT token in the Connection section.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Restaurant ID is extracted from token automatically, no need to send it
|
|
||||||
socket.emit('join:restaurant', {});
|
|
||||||
addEvent('INFO', 'Joining restaurant room (restId will be extracted from token)...');
|
|
||||||
} else {
|
|
||||||
const cookieId = document.getElementById('cookieId').value;
|
|
||||||
if (!cookieId) {
|
|
||||||
addEvent('ERROR', 'Please enter Cookie ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
socket.emit('join:session', { cookieId });
|
|
||||||
addEvent('INFO', `Joining session room: cookie:${cookieId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function leaveRoom() {
|
|
||||||
if (!socket || !socket.connected) {
|
|
||||||
addEvent('ERROR', 'Please connect first!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentRoom) {
|
|
||||||
addEvent('WARNING', 'Not in any room');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit('leave:room', { room: currentRoom });
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatus(status, message) {
|
|
||||||
const statusEl = document.getElementById('connectionStatus');
|
|
||||||
statusEl.className = `status ${status}`;
|
|
||||||
statusEl.textContent = `Status: ${message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addEvent(type, message, data = null) {
|
|
||||||
const eventsDiv = document.getElementById('events');
|
|
||||||
const eventItem = document.createElement('div');
|
|
||||||
eventItem.className = 'event-item';
|
|
||||||
|
|
||||||
const time = new Date().toLocaleTimeString();
|
|
||||||
const typeColors = {
|
|
||||||
'INFO': '#4ec9b0',
|
|
||||||
'SUCCESS': '#4ec9b0',
|
|
||||||
'WARNING': '#dcdcaa',
|
|
||||||
'ERROR': '#f48771',
|
|
||||||
'PAGER_CREATED': '#4fc1ff',
|
|
||||||
'PAGER_STATUS_UPDATED': '#4fc1ff'
|
|
||||||
};
|
|
||||||
|
|
||||||
eventItem.innerHTML = `
|
|
||||||
<div class="event-time">${time}</div>
|
|
||||||
<div class="event-type" style="color: ${typeColors[type] || '#4ec9b0'}">[${type}]</div>
|
|
||||||
<div class="event-data">${message}</div>
|
|
||||||
${data ? `<div class="event-data" style="margin-top: 8px; color: #9cdcfe;">${JSON.stringify(data, null, 2)}</div>` : ''}
|
|
||||||
`;
|
|
||||||
|
|
||||||
eventsDiv.insertBefore(eventItem, eventsDiv.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearEvents() {
|
|
||||||
document.getElementById('events').innerHTML = '';
|
|
||||||
addEvent('INFO', 'Events log cleared');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
updateRoomInputs();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user