Add user membership and admin customer management APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-30 08:57:38 +03:30
co-authored by Cursor
parent 70c889ab4b
commit e88ec69685
5 changed files with 229 additions and 32 deletions
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "Membership" AS ENUM ('regular', 'premium');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "membership" "Membership" NOT NULL DEFAULT 'regular';
+8 -2
View File
@@ -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
+46 -1
View File
@@ -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);
}
} }
+144 -29
View File
@@ -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,
};
} }
} }
+26
View File
@@ -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;
}