Add user membership and admin customer management APIs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
70c889ab4b
commit
e88ec69685
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Membership" AS ENUM ('regular', 'premium');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "membership" "Membership" NOT NULL DEFAULT 'regular';
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user