wallet
This commit is contained in:
@@ -2,7 +2,6 @@ import { MikroOrmModule } from '@mikro-orm/nestjs';
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import dataBaseConfig from './config/mikro-orm.config';
|
import dataBaseConfig from './config/mikro-orm.config';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { UserModule } from './modules/users/user.module';
|
|
||||||
import { UtilsModule } from './modules/utils/utils.module';
|
import { UtilsModule } from './modules/utils/utils.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { UploaderModule } from './modules/uploader/uploader.module';
|
import { UploaderModule } from './modules/uploader/uploader.module';
|
||||||
@@ -24,7 +23,6 @@ import { CatalogueModule } from './modules/catalogue/catalogue.module';
|
|||||||
ConfigModule.forRoot({ isGlobal: true, cache: true }),
|
ConfigModule.forRoot({ isGlobal: true, cache: true }),
|
||||||
CacheModule.registerAsync(cacheConfig()),
|
CacheModule.registerAsync(cacheConfig()),
|
||||||
MikroOrmModule.forRootAsync(dataBaseConfig),
|
MikroOrmModule.forRootAsync(dataBaseConfig),
|
||||||
UserModule,
|
|
||||||
UtilsModule,
|
UtilsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UploaderModule,
|
UploaderModule,
|
||||||
|
|||||||
@@ -21,10 +21,8 @@ export class ResponseInterceptor implements NestInterceptor {
|
|||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map(data => {
|
map(data => {
|
||||||
console.log('data',data)
|
// If data is already wrapped or doesn't need wrapping, return as-is
|
||||||
// If data is already wrapped or doesn't need wrapping, return as-is
|
|
||||||
if (data && typeof data === 'object' && 'data' in data && 'success' in data) {
|
if (data && typeof data === 'object' && 'data' in data && 'success' in data) {
|
||||||
console.log('data', data)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: data,
|
data: data,
|
||||||
|
|||||||
@@ -2,23 +2,20 @@ import { Module, forwardRef } from '@nestjs/common';
|
|||||||
import { AuthService } from './services/auth.service';
|
import { AuthService } from './services/auth.service';
|
||||||
import { AuthController } from './controllers/auth.controller';
|
import { AuthController } from './controllers/auth.controller';
|
||||||
import { UtilsModule } from '../utils/utils.module';
|
import { UtilsModule } from '../utils/utils.module';
|
||||||
import { UserModule } from '../users/user.module';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { AdminModule } from '../admin/admin.module';
|
import { AdminModule } from '../admin/admin.module';
|
||||||
import { TokensService } from './services/tokens.service';
|
import { TokensService } from './services/tokens.service';
|
||||||
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
||||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { AdminAuthGuard } from './guards/adminAuth.guard';
|
||||||
import { AdminAuthGuard } from './guards/adminAuth.guard';
|
|
||||||
import { RefreshToken } from './entities/refresh-token.entity';
|
import { RefreshToken } from './entities/refresh-token.entity';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UtilsModule,
|
UtilsModule,
|
||||||
forwardRef(() => UserModule),
|
JwtModule.registerAsync({
|
||||||
JwtModule.registerAsync({
|
|
||||||
useFactory: (configService: ConfigService) => {
|
useFactory: (configService: ConfigService) => {
|
||||||
const expiresIn = configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
|
const expiresIn = configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
|
||||||
return {
|
return {
|
||||||
@@ -34,7 +31,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
|||||||
}),
|
}),
|
||||||
forwardRef(() => AdminModule),
|
forwardRef(() => AdminModule),
|
||||||
forwardRef(() => RestaurantsModule),
|
forwardRef(() => RestaurantsModule),
|
||||||
MikroOrmModule.forFeature([User, RefreshToken]),
|
MikroOrmModule.forFeature([RefreshToken]),
|
||||||
forwardRef(() => NotificationsModule),
|
forwardRef(() => NotificationsModule),
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ export class AuthController {
|
|||||||
return this.authService.requestOtp(dto, false);
|
return this.authService.requestOtp(dto, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('public/auth/otp/verify')
|
|
||||||
@ApiOperation({ summary: 'Verify OTP code' })
|
|
||||||
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
|
|
||||||
otpVerify(@Body() dto: VerifyOtpDto) {
|
|
||||||
return this.authService.verifyOtp(dto.phone, dto.slug, dto.otp); // assuming dto has `otp` property
|
|
||||||
}
|
|
||||||
|
|
||||||
@RefreshTokenRateLimit()
|
@RefreshTokenRateLimit()
|
||||||
@ApiOperation({ summary: 'refresh the user access token / refresh token' })
|
@ApiOperation({ summary: 'refresh the user access token / refresh token' })
|
||||||
|
|||||||
@@ -2,15 +2,13 @@ import { Injectable, BadRequestException } from '@nestjs/common';
|
|||||||
import { RequestOtpDto } from '../dto/request-otp.dto';
|
import { RequestOtpDto } from '../dto/request-otp.dto';
|
||||||
import { CacheService } from '../../utils/cache.service';
|
import { CacheService } from '../../utils/cache.service';
|
||||||
import { SmsService } from '../../notifications/services/sms.service';
|
import { SmsService } from '../../notifications/services/sms.service';
|
||||||
import { UserService } from '../../users/providers/user.service';
|
import { randomInt } from 'crypto';
|
||||||
import { randomInt } from 'crypto';
|
|
||||||
import { TokensService } from './tokens.service';
|
import { TokensService } from './tokens.service';
|
||||||
import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
|
import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
|
||||||
import { AuthMessage, RestMessage } from 'src/common/enums/message.enum';
|
import { AuthMessage, RestMessage } from 'src/common/enums/message.enum';
|
||||||
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
||||||
import { AdminLoginTransformer } from '../transformers/admin-login.transformer';
|
import { AdminLoginTransformer } from '../transformers/admin-login.transformer';
|
||||||
import { UserLoginTransformer } from '../transformers/user-login.transformer';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { normalizePhone } from '../../utils/phone.util';
|
import { normalizePhone } from '../../utils/phone.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -20,8 +18,7 @@ export class AuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
private readonly smsService: SmsService,
|
private readonly smsService: SmsService,
|
||||||
private readonly userService: UserService,
|
private readonly tokensService: TokensService,
|
||||||
private readonly tokensService: TokensService,
|
|
||||||
private readonly restRepository: RestRepository,
|
private readonly restRepository: RestRepository,
|
||||||
private readonly adminRepository: AdminRepository,
|
private readonly adminRepository: AdminRepository,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@@ -56,29 +53,7 @@ export class AuthService {
|
|||||||
return { code: null };
|
return { code: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyOtp(phone: string, slug: string, code: string) {
|
|
||||||
const normalizedPhone = normalizePhone(phone);
|
|
||||||
const key = this.userOtpKey(slug, normalizedPhone);
|
|
||||||
const cachedCode = await this.cacheService.get(key);
|
|
||||||
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
|
|
||||||
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
|
|
||||||
|
|
||||||
await this.cacheService.del(key);
|
|
||||||
|
|
||||||
const user = await this.userService.findOrCreateByPhone(normalizedPhone);
|
|
||||||
|
|
||||||
const rest = await this.restRepository.findOne({ slug });
|
|
||||||
|
|
||||||
if (!rest) {
|
|
||||||
throw new BadRequestException(RestMessage.NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await this.tokensService.generateAccessAndRefreshToken(user.id, rest.id, false, slug);
|
|
||||||
|
|
||||||
const userResponse = UserLoginTransformer.transform(user, rest);
|
|
||||||
|
|
||||||
return { tokens, user: userResponse };
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyOtpAdmin(phone: string, slug: string, code: string) {
|
async verifyOtpAdmin(phone: string, slug: string, code: string) {
|
||||||
const normalizedPhone = normalizePhone(phone);
|
const normalizedPhone = normalizePhone(phone);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { User } from '../../users/entities/user.entity';
|
|
||||||
import type { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
|
||||||
|
|
||||||
export interface UserLoginResponse {
|
export interface UserLoginResponse {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,19 +13,4 @@ export interface UserLoginResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserLoginTransformer {
|
|
||||||
static transform(user: User, restaurant: Restaurant): UserLoginResponse {
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
phone: user.phone,
|
|
||||||
isActive: user.isActive,
|
|
||||||
restaurant: {
|
|
||||||
id: restaurant.id,
|
|
||||||
name: restaurant.name,
|
|
||||||
slug: restaurant.slug,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,14 +54,6 @@ export class FoodController {
|
|||||||
return this.foodsService.toggleFavorite(userId, foodId);
|
return this.foodsService.toggleFavorite(userId, foodId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('public/foods/favorite')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'get my favorites' })
|
|
||||||
getMyFavorites(@UserId() userId: string, @RestId() restId: string) {
|
|
||||||
return this.foodsService.getMyFavorites(userId, restId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------- Admin ---------------------------------- */
|
/* ---------------------------------- Admin ---------------------------------- */
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Entity, ManyToOne, Unique } from '@mikro-orm/core';
|
import { Entity, ManyToOne, Unique } from '@mikro-orm/core';
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
import { Food } from '../../foods/entities/food.entity';
|
|
||||||
|
|
||||||
@Entity({ tableName: 'favorites' })
|
@Entity({ tableName: 'favorites' })
|
||||||
@Unique({ properties: ['user', 'food'] })
|
export class Favorite extends BaseEntity {
|
||||||
export class Favorite extends BaseEntity {
|
|
||||||
@ManyToOne(() => User)
|
|
||||||
user: User;
|
|
||||||
|
|
||||||
@ManyToOne(() => Food)
|
@ManyToOne(() => Food)
|
||||||
food: Food;
|
food: Food;
|
||||||
|
|||||||
@@ -82,9 +82,7 @@ export class FoodService {
|
|||||||
const food = await this.foodRepository.findOne({ id: foodId, isActive: true }, { populate: ['category',] });
|
const food = await this.foodRepository.findOne({ id: foodId, isActive: true }, { populate: ['category',] });
|
||||||
if (!food) throw new NotFoundException(FoodMessage.NOT_FOUND);
|
if (!food) throw new NotFoundException(FoodMessage.NOT_FOUND);
|
||||||
let isFavorite = false;
|
let isFavorite = false;
|
||||||
if (userId) {
|
|
||||||
isFavorite = (await this.em.count(Favorite, { user: { id: userId }, food: { id: foodId } })) > 0;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...food,
|
...food,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
@@ -230,21 +228,18 @@ export class FoodService {
|
|||||||
|
|
||||||
async toggleFavorite(userId: string, foodId: string) {
|
async toggleFavorite(userId: string, foodId: string) {
|
||||||
|
|
||||||
const favorite = await this.em.findOne(Favorite, { user: userId, food: foodId });
|
const favorite = await this.em.findOne(Favorite, { food: foodId });
|
||||||
if (favorite) {
|
if (favorite) {
|
||||||
return this.em.removeAndFlush(favorite);
|
return this.em.removeAndFlush(favorite);
|
||||||
}
|
}
|
||||||
const newFavorite = this.em.create(Favorite, {
|
const newFavorite = this.em.create(Favorite, {
|
||||||
user: userId,
|
|
||||||
food: foodId,
|
food: foodId,
|
||||||
});
|
});
|
||||||
return this.em.persistAndFlush(newFavorite);
|
return this.em.persistAndFlush(newFavorite);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getMyFavorites(userId: string, restId: string) {
|
|
||||||
return this.em.find(Favorite, { user: userId, food: { restaurant: { id: restId } } }, { populate: ['food'] });
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Invalidate cache for restaurant foods
|
* Invalidate cache for restaurant foods
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
import { Controller, Get, Body, Param, UseGuards, Query, Patch, Put, Post } from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiHeader } from '@nestjs/swagger';
|
|
||||||
import { NotificationService } from '../services/notification.service';
|
|
||||||
import { NotificationPreferenceService } from '../services/notification-preference.service';
|
|
||||||
import { AuthGuard } from '../../auth/guards/auth.guard';
|
|
||||||
import { UserId } from '../../../common/decorators/user-id.decorator';
|
|
||||||
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
|
||||||
import { RestId } from '../../../common/decorators/rest-id.decorator';
|
|
||||||
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
|
|
||||||
import { CreatePreferenceDto } from '../dto/create-preference.dto';
|
|
||||||
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
|
||||||
import { API_HEADER_SLUG } from 'src/common/constants/index';
|
|
||||||
import { NotificationMessage } from 'src/common/enums/message.enum';
|
|
||||||
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
|
|
||||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
|
||||||
import { Permission } from 'src/common/enums/permission.enum';
|
|
||||||
|
|
||||||
@ApiTags('notifications')
|
|
||||||
@Controller()
|
|
||||||
export class NotificationsController {
|
|
||||||
constructor(
|
|
||||||
private readonly notificationService: NotificationService,
|
|
||||||
private readonly preferenceService: NotificationPreferenceService,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Get('public/notifications')
|
|
||||||
@ApiOperation({ summary: 'Get user restaurant notifications' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of items to return (default: 50)' })
|
|
||||||
@ApiQuery({
|
|
||||||
name: 'cursor',
|
|
||||||
required: false,
|
|
||||||
type: String,
|
|
||||||
description: 'Cursor for pagination (ID of the last item from previous page)',
|
|
||||||
})
|
|
||||||
@ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' })
|
|
||||||
async getUserNotifications(
|
|
||||||
@UserId() userId: string,
|
|
||||||
@RestId() restaurantId: string,
|
|
||||||
@Query('limit') limit?: number,
|
|
||||||
@Query('cursor') cursor?: string,
|
|
||||||
@Query('status') status?: 'seen' | 'unseen',
|
|
||||||
) {
|
|
||||||
return await this.notificationService.findByUserAndRestaurant(
|
|
||||||
userId,
|
|
||||||
restaurantId,
|
|
||||||
limit ? parseInt(limit.toString(), 10) : 50,
|
|
||||||
cursor,
|
|
||||||
status,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Get('public/notifications/unseen-count')
|
|
||||||
@ApiOperation({ summary: 'Get unseen notifications count for user' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
async getUserUnseenCount(@UserId() userId: string, @RestId() restaurantId: string) {
|
|
||||||
const count = await this.notificationService.countUnseenByUserAndRestaurant(userId, restaurantId);
|
|
||||||
return { count };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Put('public/notifications/:id')
|
|
||||||
@ApiOperation({ summary: 'Read a notification ' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Notification ID' })
|
|
||||||
async readNotificationUser(@RestId() restaurantId: string, @Param('id') id: string, @UserId() userId: string) {
|
|
||||||
await this.notificationService.readNotificationAsUser(id, userId, restaurantId);
|
|
||||||
return { message: NotificationMessage.READ_SUCCESS };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Put('public/notifications/read/all')
|
|
||||||
@ApiOperation({ summary: 'Read all notification ' })
|
|
||||||
async readAllNotificationUser(@RestId() restaurantId: string, @UserId() userId: string) {
|
|
||||||
await this.notificationService.readAllNotifsAsUser(userId, restaurantId);
|
|
||||||
return { message: NotificationMessage.READ_SUCCESS };
|
|
||||||
}
|
|
||||||
/* ***************** Admin Endpoints ***************** */
|
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Get('admin/notifications')
|
|
||||||
@ApiOperation({ summary: 'Get Admin notifications' })
|
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
|
||||||
@ApiQuery({
|
|
||||||
name: 'cursor',
|
|
||||||
required: false,
|
|
||||||
type: String,
|
|
||||||
description: 'Cursor for pagination (ID of the last item from previous page)',
|
|
||||||
})
|
|
||||||
@ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' })
|
|
||||||
async getAdminNotifications(
|
|
||||||
@AdminId() adminId: string,
|
|
||||||
@RestId() restaurantId: string,
|
|
||||||
@Query('limit') limit?: number,
|
|
||||||
@Query('cursor') cursor?: string,
|
|
||||||
@Query('status') status?: 'seen' | 'unseen',
|
|
||||||
) {
|
|
||||||
const parsedLimit = limit ? parseInt(limit.toString(), 10) : 50;
|
|
||||||
return await this.notificationService.findByRestaurant(restaurantId, adminId, parsedLimit, cursor, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Get('admin/notifications/unseen-count')
|
|
||||||
@ApiOperation({ summary: 'Get unseen notifications count for admin' })
|
|
||||||
async getAdminUnseenCount(@AdminId() adminId: string, @RestId() restaurantId: string) {
|
|
||||||
const count = await this.notificationService.countUnseenByRestaurant(adminId, restaurantId);
|
|
||||||
return { count };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Put('admin/notifications/:id')
|
|
||||||
@ApiOperation({ summary: 'Read a notification ' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Notification ID' })
|
|
||||||
async readNotificationAdmin(@RestId() restaurantId: string, @AdminId() adminId: string, @Param('id') id: string) {
|
|
||||||
await this.notificationService.readNotificationAdmin(id, adminId, restaurantId);
|
|
||||||
return { message: NotificationMessage.READ_SUCCESS };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Put('admin/notifications/read/all')
|
|
||||||
@ApiOperation({ summary: 'Read all notification ' })
|
|
||||||
async readAllNotificationAdmin(@RestId() restaurantId: string, @AdminId() adminId: string, @Param('id') id: string) {
|
|
||||||
await this.notificationService.readAllNotifsAsAdmin(adminId, restaurantId);
|
|
||||||
return { message: NotificationMessage.READ_SUCCESS };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Permissions(Permission.MANAGE_SETTINGS)
|
|
||||||
@Get('admin/notification-preferences')
|
|
||||||
@ApiOperation({ summary: 'Get all notification preferences for a restaurant' })
|
|
||||||
async getPreferences(@RestId() restaurantId: string) {
|
|
||||||
return this.preferenceService.findByRestaurant(restaurantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Permissions(Permission.MANAGE_SETTINGS)
|
|
||||||
@Patch('admin/notification-preferences/:id')
|
|
||||||
@ApiOperation({ summary: 'Update notification channels' })
|
|
||||||
@ApiParam({ name: 'id', description: 'Notification preference ID' })
|
|
||||||
async updatePreference(
|
|
||||||
@RestId() restaurantId: string,
|
|
||||||
@Param('id') preferenceId: string,
|
|
||||||
@Body() dto: UpdatePreferenceDto,
|
|
||||||
) {
|
|
||||||
return this.preferenceService.updatePreference(preferenceId, restaurantId, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Permissions(Permission.MANAGE_SETTINGS)
|
|
||||||
@Post('admin/notification-preferences')
|
|
||||||
@ApiOperation({ summary: 'Create notification preference' })
|
|
||||||
async createPreference(
|
|
||||||
@RestId() restaurantId: string,
|
|
||||||
@Body() dto: CreatePreferenceDto,
|
|
||||||
) {
|
|
||||||
return this.preferenceService.create(restaurantId, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Get('admin/notifications/sms-usage')
|
|
||||||
@ApiOperation({ summary: 'Get SMS usage for my restaurant' })
|
|
||||||
async getRestaurantSmsUsage(@RestId() restaurantId: string) {
|
|
||||||
const smsCount = await this.notificationService.getSmsCountByRestaurantId(restaurantId);
|
|
||||||
return { smsCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
// super admin endpoints
|
|
||||||
|
|
||||||
@UseGuards(SuperAdminAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Get('super-admin/notifications/sms-count')
|
|
||||||
@ApiOperation({ summary: 'Get SMS count for each restaurant with pagination' })
|
|
||||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)', minimum: 1 })
|
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 10)', minimum: 1 })
|
|
||||||
async getSmsCount(
|
|
||||||
@Query('page') page?: number,
|
|
||||||
@Query('limit') limit?: number,
|
|
||||||
) {
|
|
||||||
const parsedPage = page ? parseInt(page.toString(), 10) : 1;
|
|
||||||
const parsedLimit = limit ? parseInt(limit.toString(), 10) : 10;
|
|
||||||
return await this.notificationService.getSmsCountByRestaurant(parsedPage, parsedLimit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { Cron } from '@nestjs/schedule';
|
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
|
||||||
import { Notification } from '../entities/notification.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class NotificationCrone {
|
|
||||||
private readonly logger = new Logger(NotificationCrone.name);
|
|
||||||
|
|
||||||
constructor(private readonly em: EntityManager) {}
|
|
||||||
|
|
||||||
// run every day at 03:00 (3:00 AM)
|
|
||||||
@Cron('0 3 * * *', {
|
|
||||||
name: 'deleteOldNotifications',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
})
|
|
||||||
async handleCron() {
|
|
||||||
try {
|
|
||||||
this.logger.debug('Starting daily notification cleanup (removing notifications older than 24 hours)');
|
|
||||||
|
|
||||||
// Calculate the date 24 hours ago
|
|
||||||
const twentyFourHoursAgo = new Date();
|
|
||||||
twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24);
|
|
||||||
|
|
||||||
// Find all notifications created more than 24 hours ago
|
|
||||||
const oldNotifications = await this.em.find(Notification, {
|
|
||||||
createdAt: { $lt: twentyFourHoursAgo },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!oldNotifications || oldNotifications.length === 0) {
|
|
||||||
this.logger.debug('No old notifications found to delete');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Found ${oldNotifications.length} notifications older than 24 hours to delete`);
|
|
||||||
|
|
||||||
// Delete the old notifications
|
|
||||||
await this.em.removeAndFlush(oldNotifications);
|
|
||||||
|
|
||||||
this.logger.log(`Successfully deleted ${oldNotifications.length} old notifications`);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`NotificationCrone failed: ${err?.message}`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import type { ExecutionContext } from '@nestjs/common';
|
|
||||||
import { createParamDecorator } from '@nestjs/common';
|
|
||||||
import type { AuthenticatedSocket } from '../guards/ws-admin-auth.guard';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract restaurant ID from authenticated WebSocket client
|
|
||||||
* Use this decorator in WebSocket handlers to get the restId from the authenticated admin
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* @SubscribeMessage('get:notifications')
|
|
||||||
* handleGetNotifications(@WsRestId() restId: string) {
|
|
||||||
* // restId is automatically extracted from authenticated admin
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const WsRestId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
|
|
||||||
const client = ctx.switchToWs().getClient<AuthenticatedSocket>();
|
|
||||||
const restId = client.restId;
|
|
||||||
|
|
||||||
if (!restId) {
|
|
||||||
throw new Error('Restaurant ID not found. Ensure WsAdminAuthGuard is applied.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return restId;
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Entity, Property, ManyToOne, Unique } from '@mikro-orm/core';
|
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
|
||||||
import { NotifTitleEnum } from '../interfaces/notification.interface';
|
|
||||||
import { NotifChannelEnum } from '../interfaces/notification.interface';
|
|
||||||
|
|
||||||
@Entity({ tableName: 'notification_preferences' })
|
|
||||||
@Unique({ properties: ['restaurant', 'title'] })
|
|
||||||
export class NotificationPreference extends BaseEntity {
|
|
||||||
@ManyToOne(() => Restaurant)
|
|
||||||
restaurant!: Restaurant;
|
|
||||||
|
|
||||||
@Property()
|
|
||||||
title!: NotifTitleEnum;
|
|
||||||
|
|
||||||
@Property({ type: 'json' })
|
|
||||||
channels: NotifChannelEnum[] = [];
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
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 { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
import { User } from '../../users/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' })
|
||||||
@@ -10,8 +9,6 @@ export class Notification extends BaseEntity {
|
|||||||
@ManyToOne(() => Restaurant)
|
@ManyToOne(() => Restaurant)
|
||||||
restaurant!: Restaurant;
|
restaurant!: Restaurant;
|
||||||
|
|
||||||
@ManyToOne(() => User, { nullable: true })
|
|
||||||
user?: User;
|
|
||||||
|
|
||||||
@ManyToOne(() => Admin, { nullable: true })
|
@ManyToOne(() => Admin, { nullable: true })
|
||||||
admin?: Admin;
|
admin?: Admin;
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Entity, Property, ManyToOne, PrimaryKey } from '@mikro-orm/core';
|
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
|
||||||
|
|
||||||
@Entity({ tableName: 'sms_logs' })
|
|
||||||
export class SmsLog {
|
|
||||||
@PrimaryKey()
|
|
||||||
id!: number;
|
|
||||||
|
|
||||||
@ManyToOne(() => Restaurant)
|
|
||||||
restaurant!: Restaurant;
|
|
||||||
|
|
||||||
@Property()
|
|
||||||
phone!: string;
|
|
||||||
|
|
||||||
@Property()
|
|
||||||
createdAt: Date = new Date();
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export class SmsSentEvent {
|
|
||||||
constructor(
|
|
||||||
public readonly phoneNumber: string,
|
|
||||||
public readonly restaurantId: string,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
|
||||||
import { SmsSentEvent } from '../events/sms.events';
|
|
||||||
import { RestRepository } from '../../restaurants/repositories/rest.repository';
|
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
|
||||||
import { SmsLog } from '../entities/smsLogs.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SmsListeners {
|
|
||||||
private readonly logger = new Logger(SmsListeners.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly restRepository: RestRepository,
|
|
||||||
private readonly em: EntityManager,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent(SmsSentEvent.name)
|
|
||||||
async handleSmsSent(event: SmsSentEvent) {
|
|
||||||
try {
|
|
||||||
this.logger.log(
|
|
||||||
`SMS sent event received: phone ${event.phoneNumber} for restaurant: ${event.restaurantId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get the restaurant entity
|
|
||||||
const restaurant = await this.restRepository.findOne({ id: event.restaurantId });
|
|
||||||
|
|
||||||
if (!restaurant) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Restaurant not found for SMS log: ${event.restaurantId}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create SMS log record
|
|
||||||
const smsLog = this.em.create(SmsLog, {
|
|
||||||
restaurant: restaurant,
|
|
||||||
phone: event.phoneNumber,
|
|
||||||
createdAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.em.flush();
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`SMS log created successfully: ${smsLog.id} for phone ${event.phoneNumber}`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to create SMS log for event: ${event.restaurantId}`,
|
|
||||||
error instanceof Error ? error.stack : String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import {
|
|
||||||
WebSocketGateway,
|
|
||||||
WebSocketServer,
|
|
||||||
// SubscribeMessage,
|
|
||||||
OnGatewayConnection,
|
|
||||||
OnGatewayDisconnect,
|
|
||||||
// MessageBody,
|
|
||||||
// ConnectedSocket,
|
|
||||||
} from '@nestjs/websockets';
|
|
||||||
import { Server, Socket } from 'socket.io';
|
|
||||||
import { Logger, Inject, forwardRef } from '@nestjs/common';
|
|
||||||
import { NotificationService } from './services/notification.service';
|
|
||||||
import { WsAdminAuthGuard, type AuthenticatedSocket } from './guards/ws-admin-auth.guard';
|
|
||||||
import { ModuleRef } from '@nestjs/core';
|
|
||||||
import { ExecutionContext } from '@nestjs/common';
|
|
||||||
import { IInAppNotificationPayload } from './interfaces/notification.interface';
|
|
||||||
|
|
||||||
@WebSocketGateway({
|
|
||||||
cors: {
|
|
||||||
origin: true,
|
|
||||||
credentials: true,
|
|
||||||
},
|
|
||||||
namespace: '/notifications',
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
})
|
|
||||||
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|
||||||
@WebSocketServer()
|
|
||||||
server!: Server;
|
|
||||||
|
|
||||||
private readonly logger = new Logger(NotificationsGateway.name);
|
|
||||||
private readonly pingIntervals = new Map<string, NodeJS.Timeout>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(forwardRef(() => NotificationService))
|
|
||||||
private readonly notificationService: NotificationService,
|
|
||||||
private readonly moduleRef: ModuleRef,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handleConnection(client: Socket) {
|
|
||||||
try {
|
|
||||||
await this.authenticateConnection(client);
|
|
||||||
const authenticatedClient = client as AuthenticatedSocket;
|
|
||||||
this.logger.log(`Admin connected: ${authenticatedClient.adminId}`);
|
|
||||||
this.handleJoinRoom(authenticatedClient);
|
|
||||||
|
|
||||||
// Start ping interval for this client
|
|
||||||
// const intervalId = setInterval(() => {
|
|
||||||
// void authenticatedClient.emit('ping', { message: 'ping', timestamp: new Date().toISOString() });
|
|
||||||
// }, 2000);
|
|
||||||
|
|
||||||
// this.pingIntervals.set(client.id, intervalId);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`Connection authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
||||||
);
|
|
||||||
void client.emit('error', { message: 'Authentication failed' });
|
|
||||||
client.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDisconnect(client: Socket) {
|
|
||||||
this.logger.log(`Client disconnected: ${client.id}`);
|
|
||||||
this.handleLeaveRoom(client);
|
|
||||||
|
|
||||||
// Clear ping interval for this client
|
|
||||||
// const intervalId = this.pingIntervals.get(client.id);
|
|
||||||
// if (intervalId) {
|
|
||||||
// clearInterval(intervalId);
|
|
||||||
// this.pingIntervals.delete(client.id);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
sendInAppNotification(repipient: { adminId: string; restaurantId: string }, payload: IInAppNotificationPayload) {
|
|
||||||
const room = this.getRoom(repipient.adminId, repipient.restaurantId);
|
|
||||||
|
|
||||||
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`);
|
|
||||||
this.server.to(room).emit('notifications', payload, async () => {
|
|
||||||
// 3. ACK (delivered)
|
|
||||||
// await this.notificationService.markAsDelivered(payload.notificationId);
|
|
||||||
});
|
|
||||||
this.logger.log(`In app notification sent to admin: ${repipient.adminId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRoom(adminId: string, restaurantId: string) {
|
|
||||||
return `restaurant:${restaurantId}-admin:${adminId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLeaveRoom(client: AuthenticatedSocket) {
|
|
||||||
const room = this.getRoom(client.adminId!, client.restId!);
|
|
||||||
void client.leave(room);
|
|
||||||
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`);
|
|
||||||
void client.emit('left', { room, message: 'Successfully Admin left room' });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleJoinRoom(client: AuthenticatedSocket) {
|
|
||||||
const room = this.getRoom(client.adminId!, client.restId!);
|
|
||||||
void client.join(room);
|
|
||||||
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
|
|
||||||
void client.emit('joined', { room, message: 'Successfully Admin joined room' });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async authenticateConnection(client: Socket): Promise<void> {
|
|
||||||
const guard = this.moduleRef.get(WsAdminAuthGuard);
|
|
||||||
const context = {
|
|
||||||
switchToWs: () => ({ getClient: () => client }),
|
|
||||||
getClass: () => NotificationsGateway,
|
|
||||||
getHandler: () => this.handleConnection,
|
|
||||||
} as unknown as ExecutionContext;
|
|
||||||
|
|
||||||
const canActivate = await guard.canActivate(context);
|
|
||||||
if (!canActivate) {
|
|
||||||
throw new Error('Authentication failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,37 +3,26 @@ import { MikroOrmModule } from '@mikro-orm/nestjs';
|
|||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { Notification } from './entities/notification.entity';
|
import { Notification } from './entities/notification.entity';
|
||||||
import { NotificationPreference } from './entities/notification-preference.entity';
|
|
||||||
import { NotificationService } from './services/notification.service';
|
|
||||||
import { NotificationPreferenceService } from './services/notification-preference.service';
|
|
||||||
import { NotificationQueueService } from './services/notification-queue.service';
|
import { NotificationQueueService } from './services/notification-queue.service';
|
||||||
import { PushNotificationService } from './services/push-notification.service';
|
import { PushNotificationService } from './services/push-notification.service';
|
||||||
import { SmsProcessor } from './processors/sms.processor';
|
import { SmsProcessor } from './processors/sms.processor';
|
||||||
import { PushProcessor } from './processors/push.processor';
|
import { PushProcessor } from './processors/push.processor';
|
||||||
import { NotificationsController } from './controllers/notifications.controller';
|
|
||||||
import { User } from '../users/entities/user.entity';
|
|
||||||
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NotificationQueueNameEnum } from './constants/queue';
|
import { NotificationQueueNameEnum } from './constants/queue';
|
||||||
import { UtilsModule } from '../utils/utils.module';
|
import { UtilsModule } from '../utils/utils.module';
|
||||||
import { NotificationsGateway } from './notifications.gateway';
|
|
||||||
import { WsAdminAuthGuard } from './guards/ws-admin-auth.guard';
|
import { WsAdminAuthGuard } from './guards/ws-admin-auth.guard';
|
||||||
import { InAppProcessor } from './processors/in-app.processor';
|
|
||||||
import { AdminModule } from '../admin/admin.module';
|
import { AdminModule } from '../admin/admin.module';
|
||||||
import { UserModule } from '../users/user.module';
|
|
||||||
import { NotificationCrone } from './crone/notification.crone';
|
|
||||||
import { SmsService } from './services/sms.service';
|
import { SmsService } from './services/sms.service';
|
||||||
import { SmsLog } from './entities/smsLogs.entity';
|
|
||||||
import { SmsLogRepository } from './repositories/sms-log.repository';
|
|
||||||
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
||||||
import { SmsListeners } from './listeners/sms.listeners';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
forwardRef(() => AuthModule),
|
forwardRef(() => AuthModule),
|
||||||
JwtModule,
|
JwtModule,
|
||||||
MikroOrmModule.forFeature([Notification, NotificationPreference, User, Restaurant, SmsLog]),
|
MikroOrmModule.forFeature([Notification, Restaurant]),
|
||||||
BullModule.registerQueue(
|
BullModule.registerQueue(
|
||||||
{ name: NotificationQueueNameEnum.SMS },
|
{ name: NotificationQueueNameEnum.SMS },
|
||||||
{ name: NotificationQueueNameEnum.PUSH },
|
{ name: NotificationQueueNameEnum.PUSH },
|
||||||
@@ -50,27 +39,19 @@ import { SmsListeners } from './listeners/sms.listeners';
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
AdminModule,
|
AdminModule,
|
||||||
forwardRef(() => UserModule),
|
|
||||||
UtilsModule,
|
UtilsModule,
|
||||||
forwardRef(() => RestaurantsModule),
|
forwardRef(() => RestaurantsModule),
|
||||||
],
|
],
|
||||||
controllers: [NotificationsController],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
NotificationService,
|
|
||||||
NotificationPreferenceService,
|
|
||||||
NotificationQueueService,
|
NotificationQueueService,
|
||||||
PushNotificationService,
|
PushNotificationService,
|
||||||
PushProcessor,
|
PushProcessor,
|
||||||
SmsProcessor,
|
SmsProcessor,
|
||||||
InAppProcessor,
|
|
||||||
NotificationsGateway,
|
|
||||||
WsAdminAuthGuard,
|
WsAdminAuthGuard,
|
||||||
NotificationCrone,
|
|
||||||
SmsService,
|
SmsService,
|
||||||
SmsLogRepository,
|
|
||||||
SmsListeners,
|
|
||||||
],
|
],
|
||||||
exports: [NotificationService, NotificationPreferenceService,
|
exports: [
|
||||||
NotificationQueueService, PushNotificationService, SmsService],
|
NotificationQueueService, PushNotificationService, SmsService],
|
||||||
})
|
})
|
||||||
export class NotificationsModule { }
|
export class NotificationsModule { }
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Job } from 'bullmq';
|
|
||||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
|
||||||
import { Notification } from '../entities/notification.entity';
|
|
||||||
import { InAppNotificationQueueJob } from '../interfaces/jobs-queue.interface';
|
|
||||||
import { NotificationQueueNameEnum } from '../constants/queue';
|
|
||||||
import { NotificationsGateway } from '../notifications.gateway';
|
|
||||||
|
|
||||||
@Processor(NotificationQueueNameEnum.IN_APP)
|
|
||||||
export class InAppProcessor extends WorkerHost {
|
|
||||||
private readonly logger = new Logger(InAppProcessor.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(Notification)
|
|
||||||
private readonly em: EntityManager,
|
|
||||||
private readonly notificationsGateway: NotificationsGateway,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async process(job: Job<InAppNotificationQueueJob>): Promise<void> {
|
|
||||||
const { recipient, subject, body, notificationId } = job.data;
|
|
||||||
|
|
||||||
this.logger.log(`Processing InApp notification - Recipient: ${JSON.stringify(recipient)}, subject: ${subject}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.notificationsGateway.sendInAppNotification(
|
|
||||||
{ adminId: recipient.adminId, restaurantId: recipient.restaurantId },
|
|
||||||
{ subject, body, notificationId },
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`InApp notification sent successfully to ${recipient.adminId}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Error processing InApp notification job ${job.id}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnWorkerEvent('completed')
|
|
||||||
onCompleted(job: Job) {
|
|
||||||
this.logger.log(`Notification job ${job.id} completed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnWorkerEvent('failed')
|
|
||||||
onFailed(job: Job, error: Error) {
|
|
||||||
this.logger.error(`Notification job ${job.id} failed:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
|
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { UserRepository } from 'src/modules/users/repositories/user.repository';
|
import { SmsNotificationQueueJob } from '../interfaces/jobs-queue.interface';
|
||||||
import { SmsNotificationQueueJob } from '../interfaces/jobs-queue.interface';
|
|
||||||
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
||||||
import { NotificationQueueNameEnum } from '../constants/queue';
|
import { NotificationQueueNameEnum } from '../constants/queue';
|
||||||
import { SmsService } from '../services/sms.service';
|
import { SmsService } from '../services/sms.service';
|
||||||
import { SmsSentEvent } from '../events/sms.events';
|
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
@Processor(NotificationQueueNameEnum.SMS)
|
@Processor(NotificationQueueNameEnum.SMS)
|
||||||
@@ -15,8 +13,7 @@ export class SmsProcessor extends WorkerHost {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly adminRepository: AdminRepository,
|
private readonly adminRepository: AdminRepository,
|
||||||
private readonly userRepository: UserRepository,
|
private readonly smsService: SmsService,
|
||||||
private readonly smsService: SmsService,
|
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@@ -29,14 +26,7 @@ export class SmsProcessor extends WorkerHost {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let phone!: string;
|
let phone!: string;
|
||||||
if ('userId' in recipient) {
|
|
||||||
const user = await this.userRepository.findOne({ id: recipient.userId });
|
|
||||||
if (!user || !user.phone) {
|
|
||||||
this.logger.warn(`User ${recipient.userId} not found or has no phone number`);
|
|
||||||
throw new Error('User phone number is required for SMS notifications');
|
|
||||||
}
|
|
||||||
phone = user.phone;
|
|
||||||
}
|
|
||||||
if ('adminId' in recipient) {
|
if ('adminId' in recipient) {
|
||||||
const admin = await this.adminRepository.findOne({ id: recipient.adminId });
|
const admin = await this.adminRepository.findOne({ id: recipient.adminId });
|
||||||
if (!admin || !admin.phone) {
|
if (!admin || !admin.phone) {
|
||||||
@@ -58,7 +48,6 @@ 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));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
|
||||||
import { SmsLog } from '../entities/smsLogs.entity';
|
|
||||||
import { PaginatedResult } from '../../../common/interfaces/pagination.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SmsLogRepository extends EntityRepository<SmsLog> {
|
|
||||||
constructor(readonly em: EntityManager) {
|
|
||||||
super(em, SmsLog);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSmsCountByRestaurant(
|
|
||||||
page: number = 1,
|
|
||||||
limit: number = 10,
|
|
||||||
): Promise<PaginatedResult<{ restaurantId: string; restaurantName: string; smsCount: number }>> {
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
// Get total count of unique restaurants with SMS logs
|
|
||||||
const totalResult = await this.em.execute(
|
|
||||||
`
|
|
||||||
SELECT COUNT(DISTINCT sl.restaurant_id) as total
|
|
||||||
FROM sms_logs sl
|
|
||||||
WHERE sl.restaurant_id IS NOT NULL
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
const total = parseInt(totalResult[0]?.total || '0', 10);
|
|
||||||
|
|
||||||
// Get paginated results with SMS counts grouped by restaurant
|
|
||||||
const results = await this.em.execute(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
r.id as "restaurantId",
|
|
||||||
r.name as "restaurantName",
|
|
||||||
COUNT(sl.id)::int as "smsCount"
|
|
||||||
FROM sms_logs sl
|
|
||||||
INNER JOIN restaurants r ON sl.restaurant_id = r.id
|
|
||||||
GROUP BY r.id, r.name
|
|
||||||
ORDER BY "smsCount" DESC, r.name ASC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`,
|
|
||||||
[limit, offset],
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = results.map((row: any) => ({
|
|
||||||
restaurantId: row.restaurantId,
|
|
||||||
restaurantName: row.restaurantName || 'Unknown',
|
|
||||||
smsCount: parseInt(row.smsCount || '0', 10),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
meta: {
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
|
|
||||||
const result = await this.em.execute(
|
|
||||||
`
|
|
||||||
SELECT COUNT(sl.id)::int as "smsCount"
|
|
||||||
FROM sms_logs sl
|
|
||||||
WHERE sl.restaurant_id = ?
|
|
||||||
`,
|
|
||||||
[restaurantId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return parseInt(result[0]?.smsCount || '0', 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
|
||||||
import { NotificationPreference } from '../entities/notification-preference.entity';
|
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
|
||||||
import { CreatePreferenceDto } from '../dto/create-preference.dto';
|
|
||||||
import { UpdatePreferenceDto } from '../dto/update-preference.dto';
|
|
||||||
import { NotifTitleEnum } from '../interfaces/notification.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class NotificationPreferenceService {
|
|
||||||
constructor(private readonly em: EntityManager) { }
|
|
||||||
|
|
||||||
async create(restaurantId: string, dto: CreatePreferenceDto): Promise<NotificationPreference> {
|
|
||||||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
|
||||||
if (!restaurant) {
|
|
||||||
throw new NotFoundException('Restaurant not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const preference = this.em.create(NotificationPreference, {
|
|
||||||
restaurant,
|
|
||||||
channels: dto.channels,
|
|
||||||
title: dto.title,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.em.persistAndFlush(preference);
|
|
||||||
return preference;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByRestaurant(restaurantId: string): Promise<NotificationPreference[]> {
|
|
||||||
return this.em.find(NotificationPreference, {
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByRestaurantAndType(restaurantId: string, title: NotifTitleEnum): Promise<NotificationPreference | null> {
|
|
||||||
return this.em.findOne(NotificationPreference, {
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// async updateEnabled(
|
|
||||||
// restaurantId: string,
|
|
||||||
// notificationType: string,
|
|
||||||
// enabled: boolean,
|
|
||||||
// ): Promise<NotificationPreference> {
|
|
||||||
// const preference = await this.findByRestaurantAndType(restaurantId, notificationType);
|
|
||||||
// if (!preference) {
|
|
||||||
// throw new NotFoundException('Notification preference not found');
|
|
||||||
// }
|
|
||||||
|
|
||||||
// preference.enabled = enabled;
|
|
||||||
// await this.em.persistAndFlush(preference);
|
|
||||||
// return preference;
|
|
||||||
// }
|
|
||||||
|
|
||||||
async updatePreference(
|
|
||||||
preferenceId: string,
|
|
||||||
restaurantId: string,
|
|
||||||
dto: UpdatePreferenceDto,
|
|
||||||
): Promise<NotificationPreference> {
|
|
||||||
const preference = await this.em.findOne(NotificationPreference, {
|
|
||||||
id: preferenceId,
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
});
|
|
||||||
if (!preference) {
|
|
||||||
throw new NotFoundException('Notification preference not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.em.assign(preference, dto);
|
|
||||||
await this.em.persistAndFlush(preference);
|
|
||||||
return preference;
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(restaurantId: string, preferenceId: string): Promise<void> {
|
|
||||||
const preference = await this.em.findOne(NotificationPreference, {
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
id: preferenceId,
|
|
||||||
});
|
|
||||||
if (!preference) {
|
|
||||||
throw new NotFoundException('Notification preference not found');
|
|
||||||
}
|
|
||||||
await this.em.removeAndFlush(preference);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
||||||
import { EntityManager, FilterQuery } from '@mikro-orm/postgresql';
|
|
||||||
import { Notification } from '../entities/notification.entity';
|
|
||||||
import { NotificationPreferenceService } from './notification-preference.service';
|
|
||||||
import { NotificationQueueService } from './notification-queue.service';
|
|
||||||
import { NotificationsGateway } from '../notifications.gateway';
|
|
||||||
import { NotifChannelEnum, NotifRequest, NotifTitleEnum } from '../interfaces/notification.interface';
|
|
||||||
import { SmsLog } from '../entities/smsLogs.entity';
|
|
||||||
import { SmsLogRepository } from '../repositories/sms-log.repository';
|
|
||||||
import { PaginatedResult } from '../../../common/interfaces/pagination.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class NotificationService {
|
|
||||||
private readonly logger = new Logger(NotificationService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly em: EntityManager,
|
|
||||||
private readonly preferenceService: NotificationPreferenceService,
|
|
||||||
private readonly queueService: NotificationQueueService,
|
|
||||||
private readonly notificationGateway: NotificationsGateway,
|
|
||||||
private readonly smsLogRepository: SmsLogRepository,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
async sendNotification(params: NotifRequest): Promise<Notification[]> {
|
|
||||||
const { recipients, message, metadata, restaurantId } = params;
|
|
||||||
|
|
||||||
// create Database notifications
|
|
||||||
const notifications = await this.createAdminBulkNotifications(
|
|
||||||
recipients.map(recipient => ({
|
|
||||||
restaurantId,
|
|
||||||
title: message.title,
|
|
||||||
content: message.content,
|
|
||||||
adminId: 'adminId' in recipient ? recipient.adminId : null,
|
|
||||||
userId: 'userId' in recipient ? recipient.userId : null,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
// get admin prefrences
|
|
||||||
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, message.title);
|
|
||||||
|
|
||||||
if (preference?.channels?.length === 0) {
|
|
||||||
this.logger.warn(`Notification type is NONE for restaurant ${restaurantId}, title ${message.title}`);
|
|
||||||
return notifications;
|
|
||||||
}
|
|
||||||
|
|
||||||
// send in app notification
|
|
||||||
if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
|
|
||||||
await this.queueService.addBulkInAppNotifications(
|
|
||||||
notifications.map(notification => ({
|
|
||||||
recipient: { adminId: notification.admin?.id || '', restaurantId },
|
|
||||||
subject: message.title,
|
|
||||||
body: message.content,
|
|
||||||
notificationId: notification.id,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// add sms notifications to queue
|
|
||||||
if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
|
|
||||||
await this.queueService.addBulkSmsNotifications(
|
|
||||||
recipients.map(recipient => ({
|
|
||||||
recipient,
|
|
||||||
templateId: message.sms.templateId,
|
|
||||||
parameters: message.sms.parameters,
|
|
||||||
restaurantId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Queued notification for restaurant ${restaurantId}, title ${message.title}`);
|
|
||||||
|
|
||||||
return notifications;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createAdminBulkNotifications(
|
|
||||||
params: {
|
|
||||||
restaurantId: string;
|
|
||||||
title: NotifTitleEnum;
|
|
||||||
content: string;
|
|
||||||
adminId: string | null;
|
|
||||||
userId: string | null;
|
|
||||||
}[],
|
|
||||||
): Promise<Notification[]> {
|
|
||||||
const notifications = params.map(param => {
|
|
||||||
return this.em.create(Notification, {
|
|
||||||
restaurant: param.restaurantId,
|
|
||||||
admin: param.adminId,
|
|
||||||
user: param.userId && param.userId.trim() !== '' ? param.userId : null,
|
|
||||||
title: param.title,
|
|
||||||
content: param.content,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await this.em.persistAndFlush(notifications);
|
|
||||||
return notifications;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOne(id: string): Promise<Notification> {
|
|
||||||
const notification = await this.em.findOne(Notification, { id }, { populate: ['restaurant', 'user'] });
|
|
||||||
if (!notification) {
|
|
||||||
throw new NotFoundException('Notification not found');
|
|
||||||
}
|
|
||||||
return notification;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByRestaurant(
|
|
||||||
restaurantId: string,
|
|
||||||
adminId: string,
|
|
||||||
limit = 50,
|
|
||||||
cursor?: string,
|
|
||||||
status?: 'seen' | 'unseen',
|
|
||||||
): Promise<{ data: Notification[]; nextCursor: string | null }> {
|
|
||||||
const where: FilterQuery<Notification> = {
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
admin: { id: adminId },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter by status (seen/unseen)
|
|
||||||
if (status === 'seen') {
|
|
||||||
where.seenAt = { $ne: null };
|
|
||||||
} else if (status === 'unseen') {
|
|
||||||
where.seenAt = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor-based pagination: if cursor is provided, get items with id < cursor
|
|
||||||
if (cursor) {
|
|
||||||
where.id = { $lt: cursor };
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifications = await this.em.find(Notification, where, {
|
|
||||||
orderBy: { createdAt: 'DESC', id: 'DESC' },
|
|
||||||
limit: limit + 1, // fetch one extra to determine next page
|
|
||||||
populate: ['user'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasNextPage = notifications.length > limit;
|
|
||||||
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
|
||||||
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
nextCursor: nextCursor ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByUserAndRestaurant(
|
|
||||||
userId: string,
|
|
||||||
restaurantId: string,
|
|
||||||
limit = 50,
|
|
||||||
cursor?: string,
|
|
||||||
status?: 'seen' | 'unseen',
|
|
||||||
): Promise<{ data: Notification[]; nextCursor: string | null }> {
|
|
||||||
const where: FilterQuery<Notification> = {
|
|
||||||
user: { id: userId },
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter by status (seen/unseen)
|
|
||||||
if (status === 'seen') {
|
|
||||||
where.seenAt = { $ne: null };
|
|
||||||
} else if (status === 'unseen') {
|
|
||||||
where.seenAt = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor-based pagination: if cursor is provided, get items with id < cursor (since ULIDs are time-ordered)
|
|
||||||
if (cursor) {
|
|
||||||
where.id = { $lt: cursor };
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifications = await this.em.find(Notification, where, {
|
|
||||||
orderBy: { createdAt: 'DESC', id: 'DESC' },
|
|
||||||
limit: limit + 1, // Fetch one extra to determine if there's a next page
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if there's a next page
|
|
||||||
const hasNextPage = notifications.length > limit;
|
|
||||||
const data = hasNextPage ? notifications.slice(0, limit) : notifications;
|
|
||||||
const nextCursor = hasNextPage && data.length > 0 ? data[data.length - 1].id : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
nextCursor: nextCursor ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async readNotificationAdmin(id: string, adminId: string, restaurantId: string): Promise<void> {
|
|
||||||
const notification = await this.em.findOne(Notification, {
|
|
||||||
id,
|
|
||||||
admin: { id: adminId },
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
});
|
|
||||||
if (!notification) {
|
|
||||||
throw new NotFoundException('Notification not found');
|
|
||||||
}
|
|
||||||
notification.seenAt = new Date();
|
|
||||||
await this.em.persistAndFlush(notification);
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
await this.em.persistAndFlush(notification);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByRestaurantAndType(restaurantId: string, title: NotifTitleEnum, limit = 50): Promise<Notification[]> {
|
|
||||||
return this.em.find(
|
|
||||||
Notification,
|
|
||||||
{
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
title,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
orderBy: { createdAt: 'DESC' },
|
|
||||||
limit,
|
|
||||||
populate: ['user'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByAdminAndRestaurant(adminId: string, restaurantId: string, limit = 50): Promise<Notification[]> {
|
|
||||||
return this.em.find(
|
|
||||||
Notification,
|
|
||||||
{ admin: { id: adminId }, restaurant: { id: restaurantId } },
|
|
||||||
{
|
|
||||||
orderBy: { createdAt: 'DESC' },
|
|
||||||
limit,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async countUnseenByUserAndRestaurant(userId: string, restaurantId: string): Promise<number> {
|
|
||||||
const where: FilterQuery<Notification> = {
|
|
||||||
user: { id: userId },
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
seenAt: null,
|
|
||||||
};
|
|
||||||
return this.em.count(Notification, where);
|
|
||||||
}
|
|
||||||
|
|
||||||
async countUnseenByRestaurant(adminId: string, restaurantId: string): Promise<number> {
|
|
||||||
const where: FilterQuery<Notification> = {
|
|
||||||
admin: { id: adminId },
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
seenAt: null,
|
|
||||||
};
|
|
||||||
return this.em.count(Notification, where);
|
|
||||||
}
|
|
||||||
|
|
||||||
readAllNotifsAsUser(userId: string, restaurantId: string): Promise<number> {
|
|
||||||
const where: FilterQuery<Notification> = {
|
|
||||||
user: { id: userId },
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
seenAt: null,
|
|
||||||
};
|
|
||||||
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
|
||||||
}
|
|
||||||
|
|
||||||
readAllNotifsAsAdmin(adminId: string, restaurantId: string): Promise<number> {
|
|
||||||
const where: FilterQuery<Notification> = {
|
|
||||||
admin: { id: adminId },
|
|
||||||
restaurant: { id: restaurantId },
|
|
||||||
seenAt: null,
|
|
||||||
};
|
|
||||||
return this.em.nativeUpdate(Notification, where, { seenAt: new Date() });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSmsCountByRestaurant(
|
|
||||||
page: number = 1,
|
|
||||||
limit: number = 10,
|
|
||||||
): Promise<PaginatedResult<{ restaurantId: string; restaurantName: string; smsCount: number }>> {
|
|
||||||
return this.smsLogRepository.getSmsCountByRestaurant(page, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
|
|
||||||
return this.smsLogRepository.getSmsCountByRestaurantId(restaurantId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,13 +14,10 @@ import { AdminRole } from '../../admin/entities/adminRole.entity';
|
|||||||
import { Role } from '../../roles/entities/role.entity';
|
import { Role } from '../../roles/entities/role.entity';
|
||||||
import { normalizePhone } from 'src/modules/utils/phone.util';
|
import { normalizePhone } from 'src/modules/utils/phone.util';
|
||||||
import slugify from 'slugify';
|
import slugify from 'slugify';
|
||||||
import { NotificationPreference } from '../../notifications/entities/notification-preference.entity';
|
import { Category } from '../../foods/entities/category.entity';
|
||||||
import { notificationPreferencesData } from '../../../seeders/data/notification-preferences.data';
|
|
||||||
import { Category } from '../../foods/entities/category.entity';
|
|
||||||
import { Food } from '../../foods/entities/food.entity';
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
import { Schedule } from '../entities/schedule.entity';
|
import { Schedule } from '../entities/schedule.entity';
|
||||||
import { WalletTransaction } from '../../users/entities/wallet-transaction.entity';
|
|
||||||
import { SmsLog } from '../../notifications/entities/smsLogs.entity';
|
|
||||||
import { Notification } from '../../notifications/entities/notification.entity';
|
import { Notification } from '../../notifications/entities/notification.entity';
|
||||||
|
|
||||||
|
|
||||||
@@ -84,17 +81,10 @@ export class RestaurantsService {
|
|||||||
restaurant,
|
restaurant,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create notification preferences for the restaurant
|
|
||||||
const notificationPreferences = notificationPreferencesData.map(preferenceData =>
|
|
||||||
em.create(NotificationPreference, {
|
|
||||||
restaurant,
|
|
||||||
title: preferenceData.title,
|
|
||||||
channels: preferenceData.channels,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Persist all entities
|
// Persist all entities
|
||||||
em.persist([restaurant, admin, adminRole, ...notificationPreferences]);
|
em.persist([restaurant, admin, adminRole]);
|
||||||
await em.flush();
|
await em.flush();
|
||||||
|
|
||||||
return restaurant;
|
return restaurant;
|
||||||
@@ -243,20 +233,17 @@ export class RestaurantsService {
|
|||||||
await em.nativeDelete(Schedule, { restId: id });
|
await em.nativeDelete(Schedule, { restId: id });
|
||||||
|
|
||||||
// Delete notification preferences
|
// Delete notification preferences
|
||||||
await em.nativeDelete(NotificationPreference, { restaurant: id });
|
|
||||||
|
|
||||||
// Delete notifications
|
// Delete notifications
|
||||||
await em.nativeDelete(Notification, { restaurant: id });
|
await em.nativeDelete(Notification, { restaurant: id });
|
||||||
|
|
||||||
// Delete SMS logs
|
// Delete SMS logs
|
||||||
await em.nativeDelete(SmsLog, { restaurant: id });
|
|
||||||
|
|
||||||
// Delete admin roles for this restaurant
|
// Delete admin roles for this restaurant
|
||||||
await em.nativeDelete(AdminRole, { restaurant: id });
|
await em.nativeDelete(AdminRole, { restaurant: id });
|
||||||
|
|
||||||
|
|
||||||
// Delete wallet transactions
|
// Delete wallet transactions
|
||||||
await em.nativeDelete(WalletTransaction, { restaurant: id });
|
|
||||||
|
|
||||||
// Finally, delete the restaurant itself
|
// Finally, delete the restaurant itself
|
||||||
await em.nativeDelete(Restaurant, { id });
|
await em.nativeDelete(Restaurant, { id });
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
import { Controller, Get, UseGuards, Patch, Body, ValidationPipe, Post, Query, Delete, Param } from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody, ApiOkResponse, ApiHeader } from '@nestjs/swagger';
|
|
||||||
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
|
||||||
import { UserService } from '../providers/user.service';
|
|
||||||
import { UpdateUserDto } from '../dto/update-user.dto';
|
|
||||||
import { CreateUserAddressDto } from '../dto/create-user-address.dto';
|
|
||||||
import { UpdateUserAddressDto } from '../dto/update-user-address.dto';
|
|
||||||
import { UserId } from 'src/common/decorators/user-id.decorator';
|
|
||||||
import { RestId } from 'src/common/decorators';
|
|
||||||
import { FindUsersDto } from '../dto/find-user.dto';
|
|
||||||
import { FindWalletTransactionsDto } from '../dto/find-wallet-transactions.dto';
|
|
||||||
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 { Permissions } from 'src/common/decorators/permissions.decorator';
|
|
||||||
import { Permission } from 'src/common/enums/permission.enum';
|
|
||||||
import { WalletService } from '../providers/wallet.service';
|
|
||||||
|
|
||||||
@ApiTags('User')
|
|
||||||
@Controller()
|
|
||||||
export class UsersController {
|
|
||||||
constructor(
|
|
||||||
private readonly userService: UserService,
|
|
||||||
private readonly walletService: WalletService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Get the current authenticated user profile' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@Get('public/user/me')
|
|
||||||
async getUser(@UserId() userId: string) {
|
|
||||||
const user = await this.userService.findById(userId);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Update the authenticated user profile' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@ApiBody({ type: UpdateUserDto })
|
|
||||||
@Patch('public/user/update')
|
|
||||||
async updateUser(@UserId() userId: string, @RestId() restId: string, @Body() dto: UpdateUserDto) {
|
|
||||||
const user = await this.userService.updateUser(userId, restId, dto);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Get all addresses for the authenticated user' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@ApiOkResponse({ description: 'List of user addresses' })
|
|
||||||
@Get('public/user/addresses')
|
|
||||||
async getUserAddresses(@UserId() userId: string) {
|
|
||||||
const addresses = await this.userService.getUserAddresses(userId);
|
|
||||||
return addresses;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Get User Wallet' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@Get('public/user/wallet/balance')
|
|
||||||
async getUserWalletBalance(@UserId() userId: string, @RestId() restId: string) {
|
|
||||||
const wallet = await this.walletService.getUserCurrentWalletBalance(userId, restId);
|
|
||||||
return wallet;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Get User Wallet' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@Get('public/user/points/balance')
|
|
||||||
async getUserWallet(@UserId() userId: string, @RestId() restId: string) {
|
|
||||||
const wallet = await this.walletService.getUserCurrentPoinrBalance(userId, restId);
|
|
||||||
return wallet;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Get User Wallet Transactions' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@Get('public/user/wallet/transactions')
|
|
||||||
async getUserWalletTransactions(
|
|
||||||
@UserId() userId: string,
|
|
||||||
@RestId() restId: string,
|
|
||||||
@Query(new ValidationPipe({ transform: true, whitelist: true }))
|
|
||||||
query: FindWalletTransactionsDto,
|
|
||||||
) {
|
|
||||||
const transactions = await this.userService.getUserWalletTransactions(userId, restId, query);
|
|
||||||
return transactions;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Create a new address for the authenticated user' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@ApiBody({ type: CreateUserAddressDto })
|
|
||||||
@ApiOkResponse({ description: 'Created user address' })
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Get a single address by ID for the authenticated user' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@ApiOkResponse({ description: 'User address details' })
|
|
||||||
@Get('public/user/addresses/:addressId')
|
|
||||||
async getUserAddress(@UserId() userId: string, @Param('addressId') addressId: string) {
|
|
||||||
const address = await this.userService.getUserAddress(userId, addressId);
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Update an address for the authenticated user' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@ApiBody({ type: UpdateUserAddressDto })
|
|
||||||
@ApiOkResponse({ description: 'Updated user address' })
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Delete an address for the authenticated user' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@ApiOkResponse({ description: 'Address deleted successfully' })
|
|
||||||
@Delete('public/user/addresses/:addressId')
|
|
||||||
async deleteUserAddress(@UserId() userId: string, @Param('addressId') addressId: string) {
|
|
||||||
await this.userService.deleteUserAddress(userId, addressId);
|
|
||||||
return { message: UserSuccessMessage.ADDRESS_DELETED_SUCCESS };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Set an address as default for the authenticated user' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@ApiOkResponse({ description: 'Address set as default' })
|
|
||||||
@Patch('public/user/addresses/:addressId/default')
|
|
||||||
async setDefaultAddress(@UserId() userId: string, @Param('addressId') addressId: string) {
|
|
||||||
const address = await this.userService.setDefaultAddress(userId, addressId);
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({ summary: 'Convert user score (points) to wallet balance' })
|
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
|
||||||
@Post('public/user/convert-score-to-wallet')
|
|
||||||
async convertScoreToWallet(@UserId() userId: string, @RestId() restId: string) {
|
|
||||||
await this.userService.convertScoreToWallet(userId, restId);
|
|
||||||
return {
|
|
||||||
message: UserSuccessMessage.SCORE_CONVERTED_SUCCESS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************** Admin Routes **********************/
|
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@Permissions(Permission.MANAGE_USERS)
|
|
||||||
@ApiOperation({ summary: 'Get paginated list of restuarant users with filters' })
|
|
||||||
@Get('admin/users')
|
|
||||||
async findAllRestaurantUsers(
|
|
||||||
@Query(new ValidationPipe({ transform: true, whitelist: true }))
|
|
||||||
query: FindUsersDto,
|
|
||||||
@RestId() restId: string,
|
|
||||||
) {
|
|
||||||
return this.userService.findAll(restId, query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { IsString, IsOptional, IsNumber, IsBoolean, Min, Max } from 'class-validator';
|
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for creating a new user address.
|
|
||||||
*/
|
|
||||||
export class CreateUserAddressDto {
|
|
||||||
@ApiProperty({ description: 'Address title/label (e.g., "Home", "Work")', example: 'Home' })
|
|
||||||
@IsString()
|
|
||||||
title!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Street address', example: '123 Main Street' })
|
|
||||||
@IsString()
|
|
||||||
address!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'City name', example: 'Tehran' })
|
|
||||||
@IsString()
|
|
||||||
city!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Province', example: 'Tehran Province' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
province?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Postal/ZIP code', example: '12345-6789' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
postalCode?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Latitude coordinate', example: 35.6892, minimum: -90, maximum: 90 })
|
|
||||||
@IsNumber()
|
|
||||||
@Min(-90)
|
|
||||||
@Max(90)
|
|
||||||
latitude!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Longitude coordinate', example: 51.389, minimum: -180, maximum: 180 })
|
|
||||||
@IsNumber()
|
|
||||||
@Min(-180)
|
|
||||||
@Max(180)
|
|
||||||
longitude!: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Contact phone for this address', example: '+989123456789' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
phone?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Set as default address', example: false, default: false })
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isDefault?: boolean;
|
|
||||||
}
|
|
||||||
@@ -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,80 +0,0 @@
|
|||||||
import { IsOptional, IsString, IsNumber, Min, IsIn } from 'class-validator';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { User } from '../entities/user.entity';
|
|
||||||
|
|
||||||
// Define the valid sort directions
|
|
||||||
const sortOrderOptions = ['asc', 'desc'] as const;
|
|
||||||
type SortOrder = (typeof sortOrderOptions)[number];
|
|
||||||
|
|
||||||
export class FindUsersDto {
|
|
||||||
/**
|
|
||||||
* The page number to retrieve.
|
|
||||||
* @default 1
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'شماره صفحه',
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
minimum: 1,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(1)
|
|
||||||
@Type(() => Number)
|
|
||||||
page?: number = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of items per page.
|
|
||||||
* @default 10
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'تعداد مورد در هر صفحه',
|
|
||||||
type: Number,
|
|
||||||
default: 10,
|
|
||||||
minimum: 1,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(1)
|
|
||||||
@Type(() => Number)
|
|
||||||
limit?: number = 10;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A general search term to filter by.
|
|
||||||
* Searches firstName, lastName, phone, and nationalCode.
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'جستجو بر اساس نام، نامخانوادگی یا شماره تماس',
|
|
||||||
type: String,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
search?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The field to sort the results by.
|
|
||||||
* @default "createdAt"
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'فیلد مرتبسازی',
|
|
||||||
type: String,
|
|
||||||
default: 'createdAt',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
orderBy?: keyof User = 'createdAt';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The direction to sort the results.
|
|
||||||
* @default "desc"
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'جهت مرتبسازی (asc یا desc)',
|
|
||||||
enum: sortOrderOptions,
|
|
||||||
default: 'desc',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(sortOrderOptions)
|
|
||||||
order?: SortOrder = 'desc';
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { IsOptional, IsString, IsNumber, Min, IsIn, IsDateString } from 'class-validator';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { WalletTransactionType, WalletTransactionReason } from '../interface/wallet';
|
|
||||||
|
|
||||||
// Define the valid sort directions
|
|
||||||
const sortOrderOptions = ['asc', 'desc'] as const;
|
|
||||||
type SortOrder = (typeof sortOrderOptions)[number];
|
|
||||||
|
|
||||||
export class FindWalletTransactionsDto {
|
|
||||||
/**
|
|
||||||
* The page number to retrieve.
|
|
||||||
* @default 1
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Page number',
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
minimum: 1,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(1)
|
|
||||||
@Type(() => Number)
|
|
||||||
page?: number = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of items per page.
|
|
||||||
* @default 10
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Items per page',
|
|
||||||
type: Number,
|
|
||||||
default: 10,
|
|
||||||
minimum: 1,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(1)
|
|
||||||
@Type(() => Number)
|
|
||||||
limit?: number = 10;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter by transaction type
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Transaction type filter',
|
|
||||||
enum: WalletTransactionType,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(Object.values(WalletTransactionType))
|
|
||||||
type?: WalletTransactionType;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter by transaction reason
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Transaction reason filter',
|
|
||||||
enum: WalletTransactionReason,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(Object.values(WalletTransactionReason))
|
|
||||||
reason?: WalletTransactionReason;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter transactions from this date onwards
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Start date filter (ISO 8601 format)',
|
|
||||||
type: String,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
startDate?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter transactions up to this date
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'End date filter (ISO 8601 format)',
|
|
||||||
type: String,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
endDate?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum amount filter
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Minimum amount filter',
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Type(() => Number)
|
|
||||||
minAmount?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum amount filter
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Maximum amount filter',
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Type(() => Number)
|
|
||||||
maxAmount?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The field to sort the results by.
|
|
||||||
* @default "createdAt"
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Sort field',
|
|
||||||
type: String,
|
|
||||||
default: 'createdAt',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
orderBy?: string = 'createdAt';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The direction to sort the results.
|
|
||||||
* @default "desc"
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Sort direction',
|
|
||||||
enum: sortOrderOptions,
|
|
||||||
default: 'desc',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(sortOrderOptions)
|
|
||||||
order?: SortOrder = 'desc';
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { IsString, IsOptional, IsNumber, IsBoolean, Min, Max } from 'class-validator';
|
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for updating a user address.
|
|
||||||
*/
|
|
||||||
export class UpdateUserAddressDto {
|
|
||||||
@ApiPropertyOptional({ description: 'Address title/label (e.g., "Home", "Work")', example: 'Home' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
title?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Street address', example: '123 Main Street' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
address?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'City name', example: 'Tehran' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
city?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Province', example: 'Tehran Province' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
province?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Postal/ZIP code', example: '12345-6789' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
postalCode?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Latitude coordinate', example: 35.6892, minimum: -90, maximum: 90 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(-90)
|
|
||||||
@Max(90)
|
|
||||||
latitude?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Longitude coordinate', example: 51.389, minimum: -180, maximum: 180 })
|
|
||||||
@IsOptional()
|
|
||||||
@IsNumber()
|
|
||||||
@Min(-180)
|
|
||||||
@Max(180)
|
|
||||||
longitude?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Contact phone for this address', example: '+989123456789' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
phone?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Set as default address', example: false })
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isDefault?: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { IsString, IsOptional, IsBoolean } from 'class-validator';
|
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class UpdateUserDto {
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
@@ -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,36 +0,0 @@
|
|||||||
import { Entity, Property, ManyToOne } from '@mikro-orm/core';
|
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
|
||||||
import { User } from './user.entity';
|
|
||||||
|
|
||||||
@Entity({ tableName: 'user_addresses' })
|
|
||||||
export class UserAddress extends BaseEntity {
|
|
||||||
@ManyToOne(() => User)
|
|
||||||
user!: User;
|
|
||||||
|
|
||||||
@Property()
|
|
||||||
title!: string; // e.g., "Home", "Work", "Office"
|
|
||||||
|
|
||||||
@Property()
|
|
||||||
address!: string;
|
|
||||||
|
|
||||||
@Property()
|
|
||||||
city!: string;
|
|
||||||
|
|
||||||
@Property({ nullable: true })
|
|
||||||
province?: string;
|
|
||||||
|
|
||||||
@Property({ nullable: true })
|
|
||||||
postalCode?: string | null = null;
|
|
||||||
|
|
||||||
@Property({ type: 'float' })
|
|
||||||
latitude!: number;
|
|
||||||
|
|
||||||
@Property({ type: 'float' })
|
|
||||||
longitude!: number;
|
|
||||||
|
|
||||||
@Property({ nullable: true })
|
|
||||||
phone?: string; // Contact phone for this address
|
|
||||||
|
|
||||||
@Property({ default: false })
|
|
||||||
isDefault: boolean = false; // Whether this is the default address
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Entity, Index, Property, OneToMany, Collection, Cascade } from '@mikro-orm/core';
|
|
||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
|
||||||
import { UserAddress } from './user-address.entity';
|
|
||||||
import { normalizePhone } from '../../utils/phone.util';
|
|
||||||
|
|
||||||
@Entity({ tableName: 'users' })
|
|
||||||
@Index({ properties: ['isActive'] })
|
|
||||||
export class User extends BaseEntity {
|
|
||||||
|
|
||||||
|
|
||||||
@Property()
|
|
||||||
firstName!: string;
|
|
||||||
|
|
||||||
@Property({ nullable: true })
|
|
||||||
lastName?: string;
|
|
||||||
|
|
||||||
private _phone!: string;
|
|
||||||
|
|
||||||
@Property({ unique: true })
|
|
||||||
get phone(): string {
|
|
||||||
return this._phone;
|
|
||||||
}
|
|
||||||
|
|
||||||
set phone(value: string) {
|
|
||||||
this._phone = normalizePhone(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 })
|
|
||||||
isActive?: boolean = true;
|
|
||||||
|
|
||||||
@Property({ default: true, nullable: true })
|
|
||||||
gender?: boolean;
|
|
||||||
|
|
||||||
@Property({ nullable: true })
|
|
||||||
avatarUrl?: string;
|
|
||||||
|
|
||||||
@OneToMany(() => UserAddress, address => address.user, {
|
|
||||||
cascade: [Cascade.ALL],
|
|
||||||
orphanRemoval: true,
|
|
||||||
})
|
|
||||||
addresses = new Collection<UserAddress>(this);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,357 +0,0 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
||||||
import { FilterQuery, RequiredEntityData } from '@mikro-orm/core';
|
|
||||||
import { User } from '../entities/user.entity';
|
|
||||||
import { UserAddress } from '../entities/user-address.entity';
|
|
||||||
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
|
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
|
||||||
import { UpdateUserDto } from '../dto/update-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 { UserRepository } from '../repositories/user.repository';
|
|
||||||
import { normalizePhone } from '../../utils/phone.util';
|
|
||||||
import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
|
|
||||||
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()
|
|
||||||
export class UserService {
|
|
||||||
constructor(
|
|
||||||
private readonly userRepository: UserRepository,
|
|
||||||
private readonly restaurantRepository: RestRepository,
|
|
||||||
private readonly walletTransactionRepository: WalletTransactionRepository,
|
|
||||||
private readonly walletService: WalletService,
|
|
||||||
private readonly em: EntityManager,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
async findOrCreateByPhone(phone: string): Promise<User> {
|
|
||||||
const normalizedPhone = normalizePhone(phone);
|
|
||||||
let user = await this.userRepository.findOne({ phone: normalizedPhone });
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
// 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>;
|
|
||||||
|
|
||||||
user = this.userRepository.create(createData);
|
|
||||||
await this.em.persistAndFlush(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> {
|
|
||||||
const normalizedPhone = normalizePhone(phone);
|
|
||||||
return this.userRepository.findOne({ phone: normalizedPhone });
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string): Promise<User | null> {
|
|
||||||
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 createData = _raw as unknown as RequiredEntityData<User>;
|
|
||||||
|
|
||||||
const user = this.userRepository.create(createData);
|
|
||||||
|
|
||||||
await this.em.persistAndFlush([user]);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUser(userId: string, restId: string, dto: UpdateUserDto): Promise<User> {
|
|
||||||
const user = await this.userRepository.findOne({ id: userId });
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException(`User with ID ${userId} not found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize date strings into Date objects if provided
|
|
||||||
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();
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAll(restId: string, 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> = {
|
|
||||||
// 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[]> {
|
|
||||||
const user = await this.userRepository.findOne({ id: userId }, { populate: ['addresses'] });
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException(`User with ID ${userId} not found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load addresses if not already loaded
|
|
||||||
await user.addresses.loadItems();
|
|
||||||
return user.addresses.getItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
async createUserAddress(userId: string, dto: CreateUserAddressDto): Promise<UserAddress> {
|
|
||||||
const user = await this.userRepository.findOne({ id: userId });
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException(`User with ID ${userId} not found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If setting as default, unset other default addresses
|
|
||||||
if (dto.isDefault) {
|
|
||||||
const existingAddresses = await this.em.find(UserAddress, { user: { id: userId }, isDefault: true });
|
|
||||||
for (const address of existingAddresses) {
|
|
||||||
address.isDefault = false;
|
|
||||||
}
|
|
||||||
await this.em.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new address
|
|
||||||
const address = this.em.create(UserAddress, {
|
|
||||||
user,
|
|
||||||
title: dto.title,
|
|
||||||
address: dto.address,
|
|
||||||
city: dto.city,
|
|
||||||
province: dto.province,
|
|
||||||
postalCode: dto.postalCode,
|
|
||||||
latitude: dto.latitude,
|
|
||||||
longitude: dto.longitude,
|
|
||||||
phone: dto.phone,
|
|
||||||
isDefault: dto.isDefault ?? false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.em.persistAndFlush(address);
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserAddress(userId: string, addressId: string): Promise<UserAddress> {
|
|
||||||
const address = await this.em.findOne(UserAddress, {
|
|
||||||
id: addressId,
|
|
||||||
user: { id: userId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!address) {
|
|
||||||
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUserAddress(userId: string, addressId: string, dto: UpdateUserAddressDto): Promise<UserAddress> {
|
|
||||||
const address = await this.em.findOne(UserAddress, {
|
|
||||||
id: addressId,
|
|
||||||
user: { id: userId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!address) {
|
|
||||||
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If setting as default, unset other default addresses
|
|
||||||
if (dto.isDefault === true) {
|
|
||||||
const existingAddresses = await this.em.find(UserAddress, {
|
|
||||||
user: { id: userId },
|
|
||||||
isDefault: true,
|
|
||||||
id: { $ne: addressId },
|
|
||||||
});
|
|
||||||
for (const existingAddress of existingAddresses) {
|
|
||||||
existingAddress.isDefault = false;
|
|
||||||
}
|
|
||||||
await this.em.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update address fields
|
|
||||||
this.em.assign(address, dto);
|
|
||||||
await this.em.flush();
|
|
||||||
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteUserAddress(userId: string, addressId: string): Promise<void> {
|
|
||||||
const address = await this.em.findOne(UserAddress, {
|
|
||||||
id: addressId,
|
|
||||||
user: { id: userId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!address) {
|
|
||||||
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Soft delete the address
|
|
||||||
address.deletedAt = new Date();
|
|
||||||
await this.em.persistAndFlush(address);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setDefaultAddress(userId: string, addressId: string): Promise<UserAddress> {
|
|
||||||
const address = await this.em.findOne(UserAddress, {
|
|
||||||
id: addressId,
|
|
||||||
user: { id: userId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!address) {
|
|
||||||
throw new NotFoundException(`Address with ID ${addressId} not found for user ${userId}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unset all other default addresses
|
|
||||||
const existingAddresses = await this.em.find(UserAddress, {
|
|
||||||
user: { id: userId },
|
|
||||||
isDefault: true,
|
|
||||||
id: { $ne: addressId },
|
|
||||||
});
|
|
||||||
for (const existingAddress of existingAddresses) {
|
|
||||||
existingAddress.isDefault = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set this address as default
|
|
||||||
address.isDefault = true;
|
|
||||||
await this.em.flush();
|
|
||||||
|
|
||||||
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.');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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 +0,0 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
||||||
import { FilterQuery } from '@mikro-orm/core';
|
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
|
||||||
import { WalletTransaction } from '../entities/wallet-transaction.entity';
|
|
||||||
import { WalletTransactionRepository } from '../repositories/wallet-transaction.repository';
|
|
||||||
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 { WalletTransactionType } from '../interface/wallet';
|
|
||||||
import { PointTransactionRepository } from '../repositories/point-transaction.repository';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class WalletService {
|
|
||||||
constructor(
|
|
||||||
private readonly walletTransactionRepository: WalletTransactionRepository,
|
|
||||||
private readonly pointTransactionRepository: PointTransactionRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getUserWalletTransactions(
|
|
||||||
userId: string,
|
|
||||||
restId: string,
|
|
||||||
dto: FindWalletTransactionsDto,
|
|
||||||
): Promise<PaginatedResult<WalletTransaction>> {
|
|
||||||
const {
|
|
||||||
page = 1,
|
|
||||||
limit = 10,
|
|
||||||
type,
|
|
||||||
reason,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
minAmount,
|
|
||||||
maxAmount,
|
|
||||||
orderBy = 'createdAt',
|
|
||||||
order = 'desc',
|
|
||||||
} = dto;
|
|
||||||
|
|
||||||
// Calculate pagination
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
// Find the user's wallet for this restaurant
|
|
||||||
const walletTransaction = await this.walletTransactionRepository.findOne({
|
|
||||||
user: { id: userId },
|
|
||||||
restaurant: { id: restId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!walletTransaction) {
|
|
||||||
throw new NotFoundException(`User wallet not found for user ${userId} and restaurant ${restId}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the 'where' filter query
|
|
||||||
const where: FilterQuery<WalletTransaction> = {
|
|
||||||
user: { id: userId },
|
|
||||||
restaurant: { id: restId },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add type filter
|
|
||||||
if (type) {
|
|
||||||
where.type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add reason filter
|
|
||||||
if (reason) {
|
|
||||||
where.reason = reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add date range filters
|
|
||||||
if (startDate || endDate) {
|
|
||||||
where.createdAt = {};
|
|
||||||
if (startDate) {
|
|
||||||
where.createdAt.$gte = new Date(startDate);
|
|
||||||
}
|
|
||||||
if (endDate) {
|
|
||||||
where.createdAt.$lte = new Date(endDate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add amount range filters
|
|
||||||
if (minAmount !== undefined || maxAmount !== undefined) {
|
|
||||||
where.amount = {};
|
|
||||||
if (minAmount !== undefined) {
|
|
||||||
where.amount.$gte = minAmount;
|
|
||||||
}
|
|
||||||
if (maxAmount !== undefined) {
|
|
||||||
where.amount.$lte = maxAmount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the query using findAndCount
|
|
||||||
const [transactions, total] = await this.walletTransactionRepository.findAndCount(where, {
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate total pages
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
|
||||||
|
|
||||||
// Return the paginated result
|
|
||||||
return {
|
|
||||||
data: transactions,
|
|
||||||
meta: {
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getUserWallet(userId: string, restId: string): Promise<WalletTransaction | null> {
|
|
||||||
return this.walletTransactionRepository.findOne(
|
|
||||||
{
|
|
||||||
user: { id: userId },
|
|
||||||
restaurant: { id: restId },
|
|
||||||
},
|
|
||||||
{ orderBy: { createdAt: 'DESC' } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createTransaction(
|
|
||||||
em: EntityManager,
|
|
||||||
userId: string,
|
|
||||||
restId: string,
|
|
||||||
dto: CreateWalletTransactionDto,
|
|
||||||
): Promise<WalletTransaction> {
|
|
||||||
const { amount, type, reason } = dto;
|
|
||||||
|
|
||||||
// Validate amount
|
|
||||||
if (amount <= 0) {
|
|
||||||
throw new BadRequestException('Transaction amount must be greater than 0.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the user's wallet for this restaurant
|
|
||||||
const walletTransaction = await em.findOne(WalletTransaction, {
|
|
||||||
user: { id: userId },
|
|
||||||
restaurant: { id: restId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!walletTransaction) {
|
|
||||||
throw new NotFoundException(`User wallet not found for user ${userId} and restaurant ${restId}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate new balance
|
|
||||||
const currentBalance = walletTransaction.balance || 0;
|
|
||||||
let newBalance: number;
|
|
||||||
|
|
||||||
if (type === WalletTransactionType.CREDIT) {
|
|
||||||
newBalance = currentBalance + amount;
|
|
||||||
} else if (type === WalletTransactionType.DEBIT) {
|
|
||||||
if (currentBalance < amount) {
|
|
||||||
throw new BadRequestException(`Insufficient wallet balance. Current: ${currentBalance}, Required: ${amount}.`);
|
|
||||||
}
|
|
||||||
newBalance = currentBalance - amount;
|
|
||||||
} else {
|
|
||||||
throw new BadRequestException(`Invalid transaction type: ${type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update wallet balance
|
|
||||||
walletTransaction.balance = newBalance;
|
|
||||||
await em.persistAndFlush(walletTransaction);
|
|
||||||
|
|
||||||
// Create transaction record
|
|
||||||
const transaction = em.create(WalletTransaction, {
|
|
||||||
user: walletTransaction.user,
|
|
||||||
restaurant: walletTransaction.restaurant,
|
|
||||||
amount,
|
|
||||||
type,
|
|
||||||
reason,
|
|
||||||
balance: newBalance,
|
|
||||||
});
|
|
||||||
await em.persistAndFlush([walletTransaction, transaction]);
|
|
||||||
return transaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserCurrentWalletBalance(userId: string, restuarantId: string) {
|
|
||||||
const balance = await this.walletTransactionRepository.getCurrentWalletBalance(userId, restuarantId);
|
|
||||||
return { balance };
|
|
||||||
}
|
|
||||||
async getUserCurrentPoinrBalance(userId: string, restuarantId: string) {
|
|
||||||
const balance = await this.pointTransactionRepository.getcurrentPointBalance(userId, restuarantId);
|
|
||||||
return { balance };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
|
||||||
import { User } from '../entities/user.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class UserRepository extends EntityRepository<User> {
|
|
||||||
constructor(readonly em: EntityManager) {
|
|
||||||
super(em, User);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { UserService } from './providers/user.service';
|
|
||||||
import { UsersController } from './controllers/users.controller';
|
|
||||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
|
||||||
import { User } from './entities/user.entity';
|
|
||||||
import { UserAddress } from './entities/user-address.entity';
|
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
|
||||||
import { UserRepository } from './repositories/user.repository';
|
|
||||||
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
|
||||||
import { WalletTransactionRepository } from './repositories/wallet-transaction.repository';
|
|
||||||
import { WalletService } from './providers/wallet.service';
|
|
||||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
|
||||||
import { PointTransaction } from './entities/point-transaction.entity';
|
|
||||||
import { PointTransactionRepository } from './repositories/point-transaction.repository';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [UserService, WalletService, UserRepository, WalletTransactionRepository, PointTransactionRepository],
|
|
||||||
controllers: [UsersController],
|
|
||||||
imports: [
|
|
||||||
MikroOrmModule.forFeature([User, UserAddress, WalletTransaction, PointTransaction]),
|
|
||||||
JwtModule,
|
|
||||||
RestaurantsModule,
|
|
||||||
],
|
|
||||||
exports: [UserService, WalletService, UserRepository, WalletTransactionRepository, PointTransactionRepository],
|
|
||||||
})
|
|
||||||
export class UserModule {}
|
|
||||||
@@ -6,7 +6,6 @@ import { RolesSeeder } from './roles.seeder';
|
|||||||
import { RestaurantsSeeder } from './restaurants.seeder';
|
import { RestaurantsSeeder } from './restaurants.seeder';
|
||||||
import { FoodsSeeder } from './foods.seeder';
|
import { FoodsSeeder } from './foods.seeder';
|
||||||
import { AdminsSeeder } from './admins.seeder';
|
import { AdminsSeeder } from './admins.seeder';
|
||||||
import { UsersSeeder } from './users.seeder';
|
|
||||||
|
|
||||||
export class DatabaseSeeder extends Seeder {
|
export class DatabaseSeeder extends Seeder {
|
||||||
async run(em: EntityManager) {
|
async run(em: EntityManager) {
|
||||||
@@ -52,11 +51,7 @@ export class DatabaseSeeder extends Seeder {
|
|||||||
await adminsSeeder.run(em, rolesMap, restaurantsMap);
|
await adminsSeeder.run(em, rolesMap, restaurantsMap);
|
||||||
console.info(`[Seeder] Step 12/${TOTAL_STEPS}: Done`);
|
console.info(`[Seeder] Step 12/${TOTAL_STEPS}: Done`);
|
||||||
|
|
||||||
// 13. Create Users
|
|
||||||
console.info(`[Seeder] Step 13/${TOTAL_STEPS}: Creating users`);
|
|
||||||
const usersSeeder = new UsersSeeder();
|
|
||||||
await usersSeeder.run(em);
|
|
||||||
console.info(`[Seeder] Step 13/${TOTAL_STEPS}: Done`);
|
|
||||||
|
|
||||||
|
|
||||||
// 15. Create Notifications for admins and users for all restaurants
|
// 15. Create Notifications for admins and users for all restaurants
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { NotifChannelEnum, NotifTitleEnum } from '../../modules/notifications/interfaces/notification.interface';
|
|
||||||
|
|
||||||
export interface NotificationPreferenceData {
|
|
||||||
title: NotifTitleEnum;
|
|
||||||
channels: NotifChannelEnum[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const notificationPreferencesData: NotificationPreferenceData[] = [
|
|
||||||
{
|
|
||||||
title: NotifTitleEnum.PAGER_CREATED,
|
|
||||||
channels: [NotifChannelEnum.SMS, NotifChannelEnum.PUSH, NotifChannelEnum.IN_APP],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: NotifTitleEnum.ORDER_CREATED,
|
|
||||||
channels: [NotifChannelEnum.SMS, NotifChannelEnum.PUSH, NotifChannelEnum.IN_APP],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: NotifTitleEnum.PAYMENT_SUCCESS,
|
|
||||||
channels: [NotifChannelEnum.SMS, NotifChannelEnum.PUSH, NotifChannelEnum.IN_APP],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: NotifTitleEnum.REVIEW_CREATED,
|
|
||||||
channels: [NotifChannelEnum.SMS, NotifChannelEnum.PUSH, NotifChannelEnum.IN_APP],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
|
||||||
channels: [NotifChannelEnum.SMS, NotifChannelEnum.PUSH, NotifChannelEnum.IN_APP],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
export interface UserAddressData {
|
|
||||||
userPhone: string;
|
|
||||||
title: string;
|
|
||||||
address: string;
|
|
||||||
city: string;
|
|
||||||
province?: string;
|
|
||||||
postalCode?: string;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
phone?: string;
|
|
||||||
isDefault: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const userAddressesData: UserAddressData[] = [
|
|
||||||
{
|
|
||||||
userPhone: '09362532122',
|
|
||||||
title: 'خانه',
|
|
||||||
address: 'خیابان ولیعصر، پلاک 123',
|
|
||||||
city: 'تهران',
|
|
||||||
province: 'تهران',
|
|
||||||
postalCode: '1234567890',
|
|
||||||
latitude: 35.6892,
|
|
||||||
longitude: 51.389,
|
|
||||||
phone: '09362532122',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
userPhone: '09185290775',
|
|
||||||
title: 'خانه',
|
|
||||||
address: 'خیابان انقلاب، پلاک 456',
|
|
||||||
city: 'تهران',
|
|
||||||
province: 'تهران',
|
|
||||||
postalCode: '1234567891',
|
|
||||||
latitude: 35.69,
|
|
||||||
longitude: 51.39,
|
|
||||||
phone: '09185290775',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
userPhone: '09129283395',
|
|
||||||
title: 'خانه',
|
|
||||||
address: 'خیابان آزادی، پلاک 789',
|
|
||||||
city: 'تهران',
|
|
||||||
province: 'تهران',
|
|
||||||
postalCode: '1234567892',
|
|
||||||
latitude: 35.691,
|
|
||||||
longitude: 51.391,
|
|
||||||
phone: '09129283395',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
userPhone: '09184317567',
|
|
||||||
title: 'خانه',
|
|
||||||
address: 'خیابان جمهوری، پلاک 321',
|
|
||||||
city: 'تهران',
|
|
||||||
province: 'تهران',
|
|
||||||
postalCode: '1234567893',
|
|
||||||
latitude: 35.692,
|
|
||||||
longitude: 51.392,
|
|
||||||
phone: '09184317567',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
export interface UserData {
|
|
||||||
phone: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
restaurantSlug: string;
|
|
||||||
birthDate: string;
|
|
||||||
marriageDate: string;
|
|
||||||
isActive: boolean;
|
|
||||||
gender: boolean;
|
|
||||||
wallet: number;
|
|
||||||
points: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usersData: UserData[] = [
|
|
||||||
{
|
|
||||||
phone: '09362532122',
|
|
||||||
firstName: 'مرتضی',
|
|
||||||
lastName: 'مرتضایی',
|
|
||||||
restaurantSlug: 'zhivan',
|
|
||||||
birthDate: '1990-01-01',
|
|
||||||
marriageDate: '2015-01-01',
|
|
||||||
isActive: true,
|
|
||||||
gender: true,
|
|
||||||
wallet: 250000,
|
|
||||||
points: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phone: '09185290775',
|
|
||||||
firstName: 'حمید',
|
|
||||||
lastName: 'ضرغامی',
|
|
||||||
restaurantSlug: 'boote',
|
|
||||||
birthDate: '1992-01-01',
|
|
||||||
marriageDate: '2017-01-01',
|
|
||||||
isActive: true,
|
|
||||||
gender: true,
|
|
||||||
wallet: 250000,
|
|
||||||
points: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phone: '09129283395',
|
|
||||||
firstName: 'مهرداد',
|
|
||||||
lastName: 'مظفری',
|
|
||||||
restaurantSlug: 'zhivan',
|
|
||||||
birthDate: '1988-01-01',
|
|
||||||
marriageDate: '2014-01-01',
|
|
||||||
isActive: true,
|
|
||||||
gender: true,
|
|
||||||
wallet: 250000,
|
|
||||||
points: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phone: '09184317567',
|
|
||||||
firstName: 'مارال',
|
|
||||||
lastName: 'صادقی',
|
|
||||||
restaurantSlug: 'boote',
|
|
||||||
birthDate: '1995-01-01',
|
|
||||||
marriageDate: '2020-01-01',
|
|
||||||
isActive: true,
|
|
||||||
gender: false,
|
|
||||||
wallet: 250000,
|
|
||||||
points: 100,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { EntityManager } from '@mikro-orm/core';
|
|
||||||
import { NotificationPreference } from '../modules/notifications/entities/notification-preference.entity';
|
|
||||||
import type { Restaurant } from '../modules/restaurants/entities/restaurant.entity';
|
|
||||||
import { notificationPreferencesData } from './data/notification-preferences.data';
|
|
||||||
|
|
||||||
export class NotificationPreferencesSeeder {
|
|
||||||
async run(em: EntityManager, restaurants: Restaurant[]): Promise<void> {
|
|
||||||
for (const restaurant of restaurants) {
|
|
||||||
if (!restaurant) continue;
|
|
||||||
|
|
||||||
for (const preferenceData of notificationPreferencesData) {
|
|
||||||
const existing = await em.findOne(NotificationPreference, {
|
|
||||||
restaurant: { id: restaurant.id },
|
|
||||||
title: preferenceData.title,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
const preference = em.create(NotificationPreference, {
|
|
||||||
restaurant,
|
|
||||||
title: preferenceData.title,
|
|
||||||
channels: preferenceData.channels,
|
|
||||||
});
|
|
||||||
em.persist(preference);
|
|
||||||
} else {
|
|
||||||
// Update existing preference if notification type changed
|
|
||||||
if (existing.channels.length !== preferenceData.channels.length) {
|
|
||||||
existing.channels = preferenceData.channels;
|
|
||||||
em.persist(existing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await em.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import type { EntityManager } from '@mikro-orm/core';
|
|
||||||
import { Notification } from '../modules/notifications/entities/notification.entity';
|
|
||||||
import { Admin } from '../modules/admin/entities/admin.entity';
|
|
||||||
import { User } from '../modules/users/entities/user.entity';
|
|
||||||
import { Restaurant } from '../modules/restaurants/entities/restaurant.entity';
|
|
||||||
import { NotifTitleEnum } from '../modules/notifications/interfaces/notification.interface';
|
|
||||||
|
|
||||||
export class NotificationsSeeder {
|
|
||||||
/**
|
|
||||||
* Seeds notifications for all admins and users for every restaurant.
|
|
||||||
* Ensures up to 33 notifications exist per (restaurant, admin) and (restaurant, user).
|
|
||||||
*/
|
|
||||||
async run(em: EntityManager): Promise<void> {
|
|
||||||
const restaurants = await em.find(Restaurant, {});
|
|
||||||
const admins = await em.find(Admin, {});
|
|
||||||
const users = await em.find(User, {});
|
|
||||||
|
|
||||||
const titles = Object.values(NotifTitleEnum);
|
|
||||||
|
|
||||||
for (const restaurant of restaurants) {
|
|
||||||
if (!restaurant) continue;
|
|
||||||
|
|
||||||
// Admin notifications
|
|
||||||
for (const admin of admins) {
|
|
||||||
if (!admin) continue;
|
|
||||||
|
|
||||||
const existingCount = await em.count(Notification, {
|
|
||||||
restaurant: { id: restaurant.id },
|
|
||||||
admin: { id: admin.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
const toCreate = Math.max(0, 33 - (existingCount || 0));
|
|
||||||
for (let i = 0; i < toCreate; i++) {
|
|
||||||
const notif = em.create(Notification, {
|
|
||||||
restaurant,
|
|
||||||
admin,
|
|
||||||
title: titles[i % titles.length] as NotifTitleEnum,
|
|
||||||
content: `Seeded notification ${i + 1} for admin ${admin.id} at ${restaurant.slug}`,
|
|
||||||
});
|
|
||||||
em.persist(notif);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User notifications
|
|
||||||
for (const user of users) {
|
|
||||||
if (!user) continue;
|
|
||||||
|
|
||||||
const existingCount = await em.count(Notification, {
|
|
||||||
restaurant: { id: restaurant.id },
|
|
||||||
user: { id: user.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
const toCreate = Math.max(0, 33 - (existingCount || 0));
|
|
||||||
for (let i = 0; i < toCreate; i++) {
|
|
||||||
const notif = em.create(Notification, {
|
|
||||||
restaurant,
|
|
||||||
user,
|
|
||||||
title: titles[i % titles.length] as NotifTitleEnum,
|
|
||||||
content: `Seeded notification ${i + 1} for user ${user.id} at ${restaurant.slug}`,
|
|
||||||
});
|
|
||||||
em.persist(notif);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await em.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import type { EntityManager } from '@mikro-orm/core';
|
|
||||||
import { User } from '../modules/users/entities/user.entity';
|
|
||||||
import { UserAddress } from '../modules/users/entities/user-address.entity';
|
|
||||||
import { usersData } from './data/users.data';
|
|
||||||
import { userAddressesData } from './data/user-addresses.data';
|
|
||||||
import { normalizePhone } from '../modules/utils/phone.util';
|
|
||||||
|
|
||||||
export class UsersSeeder {
|
|
||||||
async run(em: EntityManager): Promise<Map<string, User>> {
|
|
||||||
const usersMap = new Map<string, User>();
|
|
||||||
|
|
||||||
// Create users
|
|
||||||
for (const userData of usersData) {
|
|
||||||
const normalizedPhone = normalizePhone(userData.phone);
|
|
||||||
let user = await em.findOne(User, { phone: normalizedPhone });
|
|
||||||
if (!user) {
|
|
||||||
user = em.create(User, {
|
|
||||||
phone: normalizedPhone, // Use normalized phone
|
|
||||||
firstName: userData.firstName,
|
|
||||||
lastName: userData.lastName,
|
|
||||||
birthDate: new Date(userData.birthDate),
|
|
||||||
marriageDate: new Date(userData.marriageDate),
|
|
||||||
isActive: userData.isActive,
|
|
||||||
gender: userData.gender,
|
|
||||||
});
|
|
||||||
em.persist(user);
|
|
||||||
}
|
|
||||||
usersMap.set(normalizedPhone, user); // Use normalized phone as key
|
|
||||||
}
|
|
||||||
|
|
||||||
await em.flush();
|
|
||||||
|
|
||||||
// Create addresses for users
|
|
||||||
for (const addressData of userAddressesData) {
|
|
||||||
const normalizedPhone = normalizePhone(addressData.userPhone);
|
|
||||||
const user = usersMap.get(normalizedPhone);
|
|
||||||
if (!user) continue;
|
|
||||||
|
|
||||||
// Check if address already exists for this user
|
|
||||||
const existingAddress = await em.findOne(UserAddress, {
|
|
||||||
user: { id: user.id },
|
|
||||||
title: addressData.title,
|
|
||||||
address: addressData.address,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingAddress) {
|
|
||||||
// If setting as default, unset other default addresses for this user
|
|
||||||
if (addressData.isDefault) {
|
|
||||||
const existingDefaultAddresses = await em.find(UserAddress, {
|
|
||||||
user: { id: user.id },
|
|
||||||
isDefault: true,
|
|
||||||
});
|
|
||||||
for (const defaultAddress of existingDefaultAddresses) {
|
|
||||||
defaultAddress.isDefault = false;
|
|
||||||
em.persist(defaultAddress);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const address = em.create(UserAddress, {
|
|
||||||
user,
|
|
||||||
title: addressData.title,
|
|
||||||
address: addressData.address,
|
|
||||||
city: addressData.city,
|
|
||||||
province: addressData.province,
|
|
||||||
postalCode: addressData.postalCode,
|
|
||||||
latitude: addressData.latitude,
|
|
||||||
longitude: addressData.longitude,
|
|
||||||
phone: addressData.phone,
|
|
||||||
isDefault: addressData.isDefault,
|
|
||||||
});
|
|
||||||
em.persist(address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await em.flush();
|
|
||||||
return usersMap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user