From 5e9125ea448b1b02e3774e4dbb5a170e37c59d4e Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sun, 28 Jun 2026 15:05:12 +0330 Subject: [PATCH] user restuarant table --- .../Migration20260628130000_AddUserShops.ts | 20 ++++ src/modules/auth/services/auth.service.ts | 2 + src/modules/users/crone/user-shop.crone.ts | 93 +++++++++++++++++++ .../users/entities/user-shop.entity.ts | 21 +++++ src/modules/users/providers/user.service.ts | 59 +++++++----- .../repositories/user-shop.repository.ts | 10 ++ src/modules/users/user.module.ts | 9 +- 7 files changed, 188 insertions(+), 26 deletions(-) create mode 100644 database/migrations/Migration20260628130000_AddUserShops.ts create mode 100644 src/modules/users/crone/user-shop.crone.ts create mode 100644 src/modules/users/entities/user-shop.entity.ts create mode 100644 src/modules/users/repositories/user-shop.repository.ts diff --git a/database/migrations/Migration20260628130000_AddUserShops.ts b/database/migrations/Migration20260628130000_AddUserShops.ts new file mode 100644 index 0000000..d66ab45 --- /dev/null +++ b/database/migrations/Migration20260628130000_AddUserShops.ts @@ -0,0 +1,20 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260628130000_AddUserShops extends Migration { + + override async up(): Promise { + this.addSql(`create table "user_shops" ("id" char(26) not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "order_count" int not null default 0, "total_order_amount" int not null default 0, "user_id" char(26) not null, "shop_id" char(26) not null, constraint "user_shops_pkey" primary key ("id"));`); + this.addSql(`create index "user_shops_created_at_index" on "user_shops" ("created_at");`); + this.addSql(`create index "user_shops_deleted_at_index" on "user_shops" ("deleted_at");`); + this.addSql(`create index "user_shops_user_id_shop_id_index" on "user_shops" ("user_id", "shop_id");`); + this.addSql(`alter table "user_shops" add constraint "user_shops_user_id_shop_id_unique" unique ("user_id", "shop_id");`); + + this.addSql(`alter table "user_shops" add constraint "user_shops_user_id_foreign" foreign key ("user_id") references "users" ("id") on update cascade on delete cascade;`); + this.addSql(`alter table "user_shops" add constraint "user_shops_shop_id_foreign" foreign key ("shop_id") references "shops" ("id") on update cascade on delete cascade;`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "user_shops" cascade;`); + } + +} diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index f4fe084..e945946 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -75,6 +75,8 @@ export class AuthService { throw new BadRequestException(ShopMessage.NOT_FOUND); } + await this.userService.ensureUserShopMembership(user.id, shop.id); + const tokens = await this.tokensService.generateTokens(user.id, shop.id, false, slug); const userResponse = UserLoginTransformer.transform(user, shop); diff --git a/src/modules/users/crone/user-shop.crone.ts b/src/modules/users/crone/user-shop.crone.ts new file mode 100644 index 0000000..e1b88d2 --- /dev/null +++ b/src/modules/users/crone/user-shop.crone.ts @@ -0,0 +1,93 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { UserShop } from '../entities/user-shop.entity'; +import { User } from '../entities/user.entity'; +import { Shop } from '../../shops/entities/shop.entity'; +import { OrderStatus } from '../../orders/interface/order.interface'; + +interface UserShopAggregateRow { + user_id: string; + shop_id: string; + order_count: string | number; + total_order_amount: string | number; +} + +const COUNTED_ORDER_STATUSES = [ + OrderStatus.PAID, + OrderStatus.PREPARING, + OrderStatus.DELIVERED_TO_RECEPIENT, + OrderStatus.SHIPPED, + OrderStatus.COMPLETED, +]; + +@Injectable() +export class UserShopCrone { + private readonly logger = new Logger(UserShopCrone.name); + + constructor(private readonly em: EntityManager) { } + + @Cron('0 3 * * *', { + name: 'syncUserShopOrderStats', + timeZone: 'UTC', + }) + async syncUserShopOrderStats() { + try { + this.logger.log('Starting daily user-shop order stats sync'); + + const statusPlaceholders = COUNTED_ORDER_STATUSES.map(() => '?').join(', '); + + const aggregates = await this.em.execute( + ` + SELECT + o.user_id, + o.shop_id, + COUNT(*)::int AS order_count, + COALESCE(SUM(o.total), 0)::int AS total_order_amount + FROM orders o + WHERE o.deleted_at IS NULL + AND o.status IN (${statusPlaceholders}) + GROUP BY o.user_id, o.shop_id + `, + COUNTED_ORDER_STATUSES, + ); + + await this.em.nativeUpdate( + UserShop, + { deletedAt: null }, + { orderCount: 0, totalOrderAmount: 0 }, + ); + + if (!aggregates.length) { + this.logger.log('No qualifying orders found; user-shop stats reset to zero'); + return; + } + + for (const row of aggregates) { + let userShop = await this.em.findOne(UserShop, { + user: row.user_id, + shop: row.shop_id, + }); + + if (!userShop) { + userShop = this.em.create(UserShop, { + user: this.em.getReference(User, row.user_id), + shop: this.em.getReference(Shop, row.shop_id), + orderCount: 0, + totalOrderAmount: 0, + }); + } + + userShop.orderCount = Number(row.order_count); + userShop.totalOrderAmount = Number(row.total_order_amount); + this.em.persist(userShop); + } + + await this.em.flush(); + + this.logger.log(`Synced order stats for ${aggregates.length} user-shop pairs`); + } catch (err) { + this.logger.error(`UserShopCrone failed: ${(err as Error)?.message}`, err); + } + } +} diff --git a/src/modules/users/entities/user-shop.entity.ts b/src/modules/users/entities/user-shop.entity.ts new file mode 100644 index 0000000..abe0efa --- /dev/null +++ b/src/modules/users/entities/user-shop.entity.ts @@ -0,0 +1,21 @@ +import { Entity, Index, ManyToOne, Property, Unique } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { User } from './user.entity'; +import { Shop } from '../../shops/entities/shop.entity'; + +@Entity({ tableName: 'user_shops' }) +@Unique({ properties: ['user', 'shop'] }) +@Index({ properties: ['user', 'shop'] }) +export class UserShop extends BaseEntity { + @ManyToOne(() => User) + user!: User; + + @ManyToOne(() => Shop) + shop!: Shop; + + @Property({ type: 'integer', default: 0 }) + orderCount: number = 0; + + @Property({ type: 'integer', default: 0 }) + totalOrderAmount: number = 0; +} diff --git a/src/modules/users/providers/user.service.ts b/src/modules/users/providers/user.service.ts index 40d738f..7e1dbaa 100644 --- a/src/modules/users/providers/user.service.ts +++ b/src/modules/users/providers/user.service.ts @@ -19,6 +19,8 @@ import { WalletService } from './wallet.service'; import { WalletTransactionReason, WalletTransactionType } from '../interface/wallet'; import { WalletTransaction } from '../entities/wallet-transaction.entity'; import { ShopMessage, UserMessage } from 'src/common/enums/message.enum'; +import { UserShop } from '../entities/user-shop.entity'; +import { UserShopRepository } from '../repositories/user-shop.repository'; @Injectable() export class UserService { @@ -27,6 +29,7 @@ export class UserService { private readonly restaurantRepository: ShopRepository, private readonly walletTransactionRepository: WalletTransactionRepository, private readonly walletService: WalletService, + private readonly userShopRepository: UserShopRepository, private readonly em: EntityManager, ) { } @@ -55,6 +58,25 @@ export class UserService { return user; } + async ensureUserShopMembership(userId: string, shopId: string): Promise { + const existing = await this.userShopRepository.findOne({ user: userId, shop: shopId }); + + if (existing) { + return existing; + } + + const userShop = this.userShopRepository.create({ + user: this.em.getReference(User, userId), + shop: this.em.getReference(Shop, shopId), + orderCount: 0, + totalOrderAmount: 0, + }); + + await this.em.persistAndFlush(userShop); + + return userShop; + } + // async updateUser(userId: string, dto: UpdateUserDto): Promise { // const user = await this.em.findOneOrFail(User, userId, {}).catch(() => { // throw new NotFoundException(UserMessage.USER_NOT_FOUND); @@ -129,46 +151,37 @@ export class UserService { async findAll(shopId: string, dto: FindUsersDto): Promise> { 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 = { - orders: { - shop: { - id: shopId, - }, - }, + const where: FilterQuery = { + shop: { id: shopId }, }; - // 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 } }] : []), - ]; + where.user = { + $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, { + const [userShops, total] = await this.userShopRepository.findAndCount(where, { limit, offset, - orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + orderBy: { user: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' } }, + populate: ['user'], }); - // 6. Calculate total pages const totalPages = Math.ceil(total / limit); - // 7. Return the paginated result return { - data: users, + data: userShops.map(userShop => userShop.user), meta: { total, page, diff --git a/src/modules/users/repositories/user-shop.repository.ts b/src/modules/users/repositories/user-shop.repository.ts new file mode 100644 index 0000000..a7e33e6 --- /dev/null +++ b/src/modules/users/repositories/user-shop.repository.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { UserShop } from '../entities/user-shop.entity'; + +@Injectable() +export class UserShopRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, UserShop); + } +} diff --git a/src/modules/users/user.module.ts b/src/modules/users/user.module.ts index f5b93c1..147c962 100644 --- a/src/modules/users/user.module.ts +++ b/src/modules/users/user.module.ts @@ -13,17 +13,20 @@ import { PointTransaction } from './entities/point-transaction.entity'; import { PointTransactionRepository } from './repositories/point-transaction.repository'; import { ShopsModule } from '../shops/shops.module'; import { AddressRepository } from './repositories/address.repository'; +import { UserShop } from './entities/user-shop.entity'; +import { UserShopRepository } from './repositories/user-shop.repository'; +import { UserShopCrone } from './crone/user-shop.crone'; @Module({ providers: [UserService, WalletService, UserRepository, - WalletTransactionRepository, PointTransactionRepository, AddressRepository], + WalletTransactionRepository, PointTransactionRepository, AddressRepository, UserShopRepository, UserShopCrone], controllers: [UsersController], imports: [ - MikroOrmModule.forFeature([User, UserAddress, WalletTransaction, PointTransaction]), + MikroOrmModule.forFeature([User, UserAddress, WalletTransaction, PointTransaction, UserShop]), JwtModule, ShopsModule ], exports: [UserService, WalletService, UserRepository, - WalletTransactionRepository, PointTransactionRepository, AddressRepository], + WalletTransactionRepository, PointTransactionRepository, AddressRepository, UserShopRepository], }) export class UserModule { }