diff --git a/prisma/migrations/20260928160000_user_membership/migration.sql b/prisma/migrations/20260928160000_user_membership/migration.sql new file mode 100644 index 0000000..1fc24ab --- /dev/null +++ b/prisma/migrations/20260928160000_user_membership/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "Membership" AS ENUM ('regular', 'premium'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "membership" "Membership" NOT NULL DEFAULT 'regular'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 954b0c1..0b759bb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,11 @@ enum UserRole { SUPER_ADMIN } +enum Membership { + regular + premium +} + enum StoreStatus { pending active @@ -63,8 +68,9 @@ model User { about String? avatarUrl String? avatarKey String? - role UserRole @default(CUSTOMER) - disabled Boolean @default(false) + role UserRole @default(CUSTOMER) + membership Membership @default(regular) + disabled Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/customers/customers.controller.ts b/src/customers/customers.controller.ts index 44146d9..4a3074d 100644 --- a/src/customers/customers.controller.ts +++ b/src/customers/customers.controller.ts @@ -1,4 +1,12 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + UseGuards, +} from '@nestjs/common'; import { UserRole } from '@prisma/client'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; @@ -6,6 +14,11 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import type { AuthUser } from '../auth/auth.types'; import { CustomersService } from './customers.service'; +import { + UpdateCustomerDto, + UpdateCustomerMembershipDto, + UpdateCustomerRoleDto, +} from './dto/customer.dto'; @Controller('customers') @UseGuards(JwtAuthGuard, RolesGuard) @@ -17,4 +30,36 @@ export class CustomersController { list(@CurrentUser() user: AuthUser) { return this.customers.list(user); } + + @Patch(':id') + update( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() dto: UpdateCustomerDto, + ) { + return this.customers.update(user, id, dto); + } + + @Patch(':id/role') + updateRole( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() dto: UpdateCustomerRoleDto, + ) { + return this.customers.updateRole(user, id, dto); + } + + @Patch(':id/membership') + updateMembership( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() dto: UpdateCustomerMembershipDto, + ) { + return this.customers.updateMembership(user, id, dto); + } + + @Delete(':id') + remove(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.customers.remove(user, id); + } } diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts index 0602adc..6c2bd58 100644 --- a/src/customers/customers.service.ts +++ b/src/customers/customers.service.ts @@ -1,8 +1,35 @@ -import { Injectable } from '@nestjs/common'; -import { UserRole } from '@prisma/client'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Membership, Prisma, UserRole } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { StoreScopeService } from '../common/store-scope.service'; import { PrismaService } from '../prisma/prisma.service'; +import { + UpdateCustomerDto, + UpdateCustomerMembershipDto, + UpdateCustomerRoleDto, +} from './dto/customer.dto'; + +const userSelect = { + id: true, + firstName: true, + lastName: true, + cellNumber: true, + email: true, + role: true, + membership: true, + disabled: true, + createdAt: true, + _count: { select: { orders: true } }, + ownedStore: { select: { id: true } }, +} satisfies Prisma.UserSelect; + +type UserRow = Prisma.UserGetPayload<{ select: typeof userSelect }>; @Injectable() export class CustomersService { @@ -12,39 +39,127 @@ export class CustomersService { ) {} async list(actor: AuthUser) { - // Marketplace customers are shared across stores; store admins need the - // full customer directory even before any orders exist for their store. if ( - actor.role === UserRole.SUPER_ADMIN || - actor.role === UserRole.STORE_ADMIN + actor.role !== UserRole.SUPER_ADMIN && + actor.role !== UserRole.STORE_ADMIN ) { - const rows = await this.prisma.user.findMany({ - where: { role: UserRole.CUSTOMER }, - select: this.customerSelect(), - orderBy: { createdAt: 'desc' }, - }); - return rows.map((customer) => ({ - ...customer, - name: `${customer.firstName} ${customer.lastName}`.trim(), - })); + await this.storeScope.requireActiveStore(actor); + return []; } - await this.storeScope.requireActiveStore(actor); - return []; + const rows = await this.prisma.user.findMany({ + select: userSelect, + orderBy: { createdAt: 'desc' }, + }); + return rows.map((user) => this.present(user)); } - private customerSelect() { + async update(actor: AuthUser, id: string, dto: UpdateCustomerDto) { + await this.requireTarget(actor, id); + const taken = await this.prisma.user.findFirst({ + where: { cellNumber: dto.cellNumber, NOT: { id } }, + select: { id: true }, + }); + if (taken) { + throw new ConflictException('این شماره قبلاً ثبت شده است'); + } + + const user = await this.prisma.user.update({ + where: { id }, + data: { + firstName: dto.firstName.trim(), + lastName: dto.lastName.trim(), + cellNumber: dto.cellNumber, + }, + select: userSelect, + }); + return this.present(user); + } + + async updateRole(actor: AuthUser, id: string, dto: UpdateCustomerRoleDto) { + if (actor.id === id) { + throw new BadRequestException('نمی‌توانید نقش خود را تغییر دهید'); + } + const target = await this.requireTarget(actor, id); + if ( + dto.role === UserRole.SUPER_ADMIN && + actor.role !== UserRole.SUPER_ADMIN + ) { + throw new ForbiddenException('فقط مدیر کل می‌تواند این نقش را بدهد'); + } + if (target.ownedStore && dto.role !== UserRole.STORE_ADMIN) { + throw new BadRequestException('مالک فروشگاه باید مدیر فروشگاه بماند'); + } + + const user = await this.prisma.user.update({ + where: { id }, + data: { role: dto.role }, + select: userSelect, + }); + return this.present(user); + } + + async updateMembership( + actor: AuthUser, + id: string, + dto: UpdateCustomerMembershipDto, + ) { + await this.requireTarget(actor, id); + const user = await this.prisma.user.update({ + where: { id }, + data: { membership: dto.membership }, + select: userSelect, + }); + return this.present(user); + } + + async remove(actor: AuthUser, id: string) { + if (actor.id === id) { + throw new BadRequestException('نمی‌توانید حساب خود را حذف کنید'); + } + const target = await this.requireTarget(actor, id); + if (target.ownedStore) { + throw new BadRequestException('مالک فروشگاه را نمی‌توان حذف کرد'); + } + await this.prisma.user.delete({ where: { id } }); + return { ok: true }; + } + + private async requireTarget(actor: AuthUser, id: string) { + if ( + actor.role !== UserRole.SUPER_ADMIN && + actor.role !== UserRole.STORE_ADMIN + ) { + throw new ForbiddenException('دسترسی مجاز نیست'); + } + + const user = await this.prisma.user.findUnique({ + where: { id }, + select: userSelect, + }); + if (!user) throw new NotFoundException('کاربر یافت نشد'); + if ( + user.role === UserRole.SUPER_ADMIN && + actor.role !== UserRole.SUPER_ADMIN + ) { + throw new ForbiddenException('دسترسی مجاز نیست'); + } + return user; + } + + private present(user: UserRow) { return { - id: true, - firstName: true, - lastName: true, - cellNumber: true, - email: true, - instagram: true, - about: true, - avatarUrl: true, - disabled: true, - createdAt: true, - } as const; + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + name: `${user.firstName} ${user.lastName}`.trim(), + cellNumber: user.cellNumber, + email: user.email, + role: user.role, + membership: user.membership as Membership, + orderCount: user._count.orders, + disabled: user.disabled, + createdAt: user.createdAt, + }; } } diff --git a/src/customers/dto/customer.dto.ts b/src/customers/dto/customer.dto.ts new file mode 100644 index 0000000..661022b --- /dev/null +++ b/src/customers/dto/customer.dto.ts @@ -0,0 +1,26 @@ +import { Membership, UserRole } from '@prisma/client'; +import { IsEnum, IsString, Matches, MinLength } from 'class-validator'; + +export class UpdateCustomerDto { + @IsString() + @MinLength(1, { message: 'نام الزامی است' }) + firstName!: string; + + @IsString() + @MinLength(1, { message: 'نام خانوادگی الزامی است' }) + lastName!: string; + + @IsString() + @Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' }) + cellNumber!: string; +} + +export class UpdateCustomerRoleDto { + @IsEnum(UserRole, { message: 'نقش نامعتبر است' }) + role!: UserRole; +} + +export class UpdateCustomerMembershipDto { + @IsEnum(Membership, { message: 'عضویت نامعتبر است' }) + membership!: Membership; +}