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
|
SUPER_ADMIN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Membership {
|
||||||
|
regular
|
||||||
|
premium
|
||||||
|
}
|
||||||
|
|
||||||
enum StoreStatus {
|
enum StoreStatus {
|
||||||
pending
|
pending
|
||||||
active
|
active
|
||||||
@@ -63,8 +68,9 @@ model User {
|
|||||||
about String?
|
about String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
avatarKey String?
|
avatarKey String?
|
||||||
role UserRole @default(CUSTOMER)
|
role UserRole @default(CUSTOMER)
|
||||||
disabled Boolean @default(false)
|
membership Membership @default(regular)
|
||||||
|
disabled Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 { UserRole } from '@prisma/client';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { Roles } from '../auth/decorators/roles.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 { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import type { AuthUser } from '../auth/auth.types';
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
import { CustomersService } from './customers.service';
|
import { CustomersService } from './customers.service';
|
||||||
|
import {
|
||||||
|
UpdateCustomerDto,
|
||||||
|
UpdateCustomerMembershipDto,
|
||||||
|
UpdateCustomerRoleDto,
|
||||||
|
} from './dto/customer.dto';
|
||||||
|
|
||||||
@Controller('customers')
|
@Controller('customers')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@@ -17,4 +30,36 @@ export class CustomersController {
|
|||||||
list(@CurrentUser() user: AuthUser) {
|
list(@CurrentUser() user: AuthUser) {
|
||||||
return this.customers.list(user);
|
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 {
|
||||||
import { UserRole } from '@prisma/client';
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Membership, Prisma, UserRole } from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { StoreScopeService } from '../common/store-scope.service';
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
import { PrismaService } from '../prisma/prisma.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()
|
@Injectable()
|
||||||
export class CustomersService {
|
export class CustomersService {
|
||||||
@@ -12,39 +39,127 @@ export class CustomersService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(actor: AuthUser) {
|
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 (
|
if (
|
||||||
actor.role === UserRole.SUPER_ADMIN ||
|
actor.role !== UserRole.SUPER_ADMIN &&
|
||||||
actor.role === UserRole.STORE_ADMIN
|
actor.role !== UserRole.STORE_ADMIN
|
||||||
) {
|
) {
|
||||||
const rows = await this.prisma.user.findMany({
|
await this.storeScope.requireActiveStore(actor);
|
||||||
where: { role: UserRole.CUSTOMER },
|
return [];
|
||||||
select: this.customerSelect(),
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
});
|
|
||||||
return rows.map((customer) => ({
|
|
||||||
...customer,
|
|
||||||
name: `${customer.firstName} ${customer.lastName}`.trim(),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.storeScope.requireActiveStore(actor);
|
const rows = await this.prisma.user.findMany({
|
||||||
return [];
|
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 {
|
return {
|
||||||
id: true,
|
id: user.id,
|
||||||
firstName: true,
|
firstName: user.firstName,
|
||||||
lastName: true,
|
lastName: user.lastName,
|
||||||
cellNumber: true,
|
name: `${user.firstName} ${user.lastName}`.trim(),
|
||||||
email: true,
|
cellNumber: user.cellNumber,
|
||||||
instagram: true,
|
email: user.email,
|
||||||
about: true,
|
role: user.role,
|
||||||
avatarUrl: true,
|
membership: user.membership as Membership,
|
||||||
disabled: true,
|
orderCount: user._count.orders,
|
||||||
createdAt: true,
|
disabled: user.disabled,
|
||||||
} as const;
|
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