Add AdminId decorator for extracting adminId from requests; refactor AdminController to utilize new decorator for fetching admin details and restaurant-specific admins; update AdminService to handle admin creation and retrieval by restaurant; remove unused role and permission entities for cleaner architecture.

This commit is contained in:
2025-11-18 16:45:44 +03:30
parent 9abab789fc
commit 8a217f6034
18 changed files with 254 additions and 206 deletions
@@ -0,0 +1,18 @@
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
import type { Request } from 'express';
/**
* Decorator to extract userId from the authenticated request.
* Must be used after AdminAuthGuard or AuthGuard that sets request.userId.
*
* @example
* @Get('/profile')
* @UseGuards(AdminAuthGuard)
* getProfile(@UserId() userId: string) {
* return this.userService.findById(userId);
* }
*/
export const AdminId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest<Request & { adminId?: string }>();
return request.adminId || '';
});
@@ -3,6 +3,8 @@ import { AdminService } from '../providers/admin.service';
import { CreateAdminDto } from '../dto/create-admin.dto';
import { ApiTags, ApiOperation, ApiBody, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
import { RestId } from 'src/common/decorators';
import { AdminId } from 'src/common/decorators/admin-id.decorator';
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@@ -11,19 +13,19 @@ import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get()
@Get('my-restaurant-admins')
@ApiOperation({ summary: 'admin' })
@ApiResponse({ status: 201, description: 'Admins' })
async getAll() {
const admin = await this.adminService.findAll();
async getAll(@RestId() restId: string) {
const admin = await this.adminService.findAllByRestaurantId(restId);
return admin;
}
@Get('me')
@ApiOperation({ summary: 'admin' })
@ApiResponse({ status: 201, description: 'Admins' })
async getMe() {
const admin = await this.adminService.findAll();
async getMe(@AdminId() adminId: string) {
const admin = await this.adminService.findById(adminId);
return admin;
}
@@ -33,7 +35,7 @@ export class AdminController {
@ApiBody({ type: CreateAdminDto })
@ApiResponse({ status: 201, description: 'Admin created' })
async create(@Body() dto: CreateAdminDto) {
const admin = await this.adminService.create({
const admin = await this.adminService.createRestaurantBySuperAdmin({
phone: dto.phone,
firstName: dto.firstName,
lastName: dto.lastName,
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateMyRestaurantAdminDto {
@ApiProperty({ description: 'Mobile phone number' })
@IsNotEmpty()
@IsString()
phone!: string;
@ApiProperty({ description: 'First name', required: false })
@IsOptional()
@IsString()
firstName?: string;
@ApiProperty({ description: 'Last name', required: false })
@IsOptional()
@IsString()
lastName?: string;
@ApiProperty({ description: 'Role id for the admin' })
@IsNotEmpty()
@IsString()
roleId!: string;
}
+7 -10
View File
@@ -1,11 +1,8 @@
import { Entity, ManyToOne, Property } from '@mikro-orm/core';
import { Cascade, Collection, Entity, OneToMany, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Role } from '../../roles/entities/role.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { AdminRole } from './adminRole.entity';
@Entity({ tableName: 'admins' })
// Add the Unique constraint for the combination of 'phone' and 'restaurant'
// @Unique({ properties: ['phone', 'restaurant'] })
export class Admin extends BaseEntity {
@Property({ nullable: true })
firstName?: string;
@@ -17,9 +14,9 @@ export class Admin extends BaseEntity {
phone!: string;
// Add the new role property
@ManyToOne(() => Role)
role!: Role;
@ManyToOne(() => Restaurant, { nullable: true })
restaurant?: Restaurant;
@OneToMany(() => AdminRole, adminRole => adminRole.admin, {
cascade: [Cascade.ALL],
orphanRemoval: true,
})
roles = new Collection<AdminRole>(this);
}
@@ -1,13 +1,12 @@
import { Entity, ManyToOne } from '@mikro-orm/core';
import { Entity, ManyToOne, Unique } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Role } from './role.entity';
import { Role } from '../../roles/entities/role.entity';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
import { Admin } from './admin.entity';
@Entity({ tableName: 'admin_roles' })
// Add the Unique constraint for the combination of 'phone' and 'restaurant'
// @Unique({ properties: ['phone', 'restaurant'] })
export class AdminRole extends BaseEntity {
@Unique({ properties: ['admin.id', 'role.id', 'restaurant.id'] })
@ManyToOne(() => Admin)
admin!: Admin;
@@ -1,16 +0,0 @@
import { Entity, Property, Unique, ManyToMany, Collection } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Role } from './role.entity';
@Entity({ tableName: 'permissions' })
export class Permission extends BaseEntity {
@Property()
@Unique()
name!: string;
@Property()
title!: string;
@ManyToMany({ entity: () => Role, mappedBy: r => r.permissions })
roles = new Collection<Role>(this);
}
-29
View File
@@ -1,29 +0,0 @@
// role.entity.ts
import { Collection, Entity, ManyToMany, OneToMany, ManyToOne, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Admin } from './admin.entity';
import { Permission } from './permission.entity';
import { Restaurant } from '../../../modules/restaurants/entities/restaurant.entity';
import { RolePermission } from './rolePermission.entity';
import { AdminRole } from './adminRole.entity';
@Entity({ tableName: 'roles' })
export class Role extends BaseEntity {
@Property()
name!: string;
@Property()
title!: string;
@ManyToMany({ entity: () => Permission, pivotEntity: () => RolePermission, inversedBy: p => p.roles })
permissions = new Collection<Permission>(this);
@OneToMany(() => Admin, admin => admin.role)
admins = new Collection<Admin>(this);
@ManyToOne(() => Restaurant, { nullable: true })
restaurant?: Restaurant;
@OneToMany(() => AdminRole, adminRoleRestaurant => adminRoleRestaurant.role)
adminRoles = new Collection<AdminRole>(this);
}
@@ -1,17 +0,0 @@
// role-permission.entity.ts (pivot)
import { Entity, ManyToOne } from '@mikro-orm/core';
import { Role } from './role.entity';
import { Permission } from './permission.entity';
@Entity({ tableName: 'role_permissions' })
export class RolePermission {
// دو ManyToOne دقیقاً؛ هر کدام می‌تونن primary: true باشند برای composite PK
@ManyToOne(() => Role, { primary: true })
role!: Role;
@ManyToOne(() => Permission, { primary: true })
permission!: Permission;
// **نکته:** اینجا *فقط* همین دو خصوصیت کافی است —
// از تعریف جداگانه‌ی roleId / permissionId با @PrimaryKey خودداری کن مگر بخوای نام/نوع ستون را سفارشی کنی.
}
+33 -71
View File
@@ -6,12 +6,10 @@ import { Role } from '../../roles/entities/role.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { EntityManager } from '@mikro-orm/postgresql';
import { CacheService } from '../../utils/cache.service';
import { AdminDetailTransformer, AdminDetailResponse } from '../transformers/admin-detail.transformer';
import { CreateMyRestaurantAdminDto } from '../dto/create-my-restaurant-admin.dto';
@Injectable()
export class AdminService {
private readonly ADMIN_PERMISSIONS_KEY = 'admin-perms';
constructor(
@InjectRepository(Admin)
private readonly adminRepository: EntityRepository<Admin>,
@@ -23,24 +21,44 @@ export class AdminService {
return this.adminRepository.findOne({ phone });
}
async findById(id: string): Promise<AdminDetailResponse | null> {
const admin = await this.adminRepository.findOne({ id }, { populate: ['role', 'role.permissions', 'restaurant'] });
async findById(id: string): Promise<Admin | null> {
const admin = await this.adminRepository.findOne({ id }, { populate: ['roles', 'roles.role'] });
return admin;
}
if (!admin) {
return null;
async findAllByRestaurantId(restId: string): Promise<Admin[]> {
return this.adminRepository.find({ roles: { restaurant: { id: restId } } }, { populate: ['roles', 'roles.role'] });
}
async createAdminForMyRestaurant(restId: string, dto: CreateMyRestaurantAdminDto) {
const { phone, firstName, lastName, roleId } = dto;
const currentAdmin = await this.adminRepository.findOne({
phone,
roles: { role: { id: roleId }, restaurant: { id: restId } },
});
if (currentAdmin) {
return currentAdmin;
}
return AdminDetailTransformer.transform(admin);
}
async findAll(): Promise<Admin[]> {
return this.adminRepository.findAll();
const role = await this.em.findOne(Role, { id: roleId });
if (!role) throw new Error('Role not found');
const restaurant = await this.em.findOne(Restaurant, { id: restId });
if (!restaurant) throw new Error('Restaurant not found');
const admin = this.adminRepository.create({ phone, firstName, lastName, roles: [{ role, restaurant }] });
await this.em.persistAndFlush(admin);
return admin;
}
async create(data: { phone: string; firstName?: string; lastName?: string; roleId: string; restId: string }) {
async createRestaurantBySuperAdmin(data: {
phone: string;
firstName?: string;
lastName?: string;
roleId: string;
restId: string;
}) {
const { phone, firstName, lastName, roleId, restId } = data;
// check existing
const existing = await this.adminRepository.findOne({ phone, restaurant: { id: restId } });
const existing = await this.adminRepository.findOne({ phone, roles: { restaurant: { id: restId } } });
if (existing) {
return existing;
}
@@ -52,64 +70,8 @@ export class AdminService {
const restaurant = await this.em.findOne(Restaurant, { id: restId });
if (!restaurant) throw new Error('Restaurant not found');
const admin = this.adminRepository.create({ phone, firstName, lastName, role, restaurant });
const admin = this.adminRepository.create({ phone, firstName, lastName, roles: [{ role, restaurant }] });
await this.em.persistAndFlush(admin);
return admin;
}
/**
* Get admin permissions from cache or database
* @param adminId - The admin ID
* @param restId - The restaurant ID
* @returns Array of permission names (string[])
*/
async getAdminPermissions(adminId: string, restId: string): Promise<string[]> {
const cacheKey = `${this.ADMIN_PERMISSIONS_KEY}:${adminId}:${restId}`;
// Try to get from cache first
const cachedPermissions = await this.cacheService.get<string>(cacheKey);
if (cachedPermissions) {
try {
const parsed: unknown = JSON.parse(cachedPermissions);
// Ensure it's an array of strings
if (Array.isArray(parsed) && parsed.every((p): p is string => typeof p === 'string')) {
return parsed;
}
// If invalid format, continue to fetch from DB
} catch {
// If parsing fails, continue to fetch from DB
}
}
// If not in cache, fetch from database
const admin = await this.adminRepository.findOne(
{ id: adminId, restaurant: { id: restId } },
{ populate: ['role', 'role.permissions'] },
);
if (!admin || !admin.role) {
return [];
}
// Extract permission names as array of strings
const permissions = await admin.role.permissions.loadItems();
const permissionNames: string[] = permissions
.map(p => p.name)
.filter((name): name is string => typeof name === 'string');
// Store in cache as JSON stringified array of strings (1 hour TTL)
await this.cacheService.set(cacheKey, JSON.stringify(permissionNames), 3600);
return permissionNames;
}
/**
* Invalidate admin permissions cache
* @param adminId - The admin ID
* @param restId - The restaurant ID
*/
async invalidateAdminPermissionsCache(adminId: string, restId: string): Promise<void> {
const cacheKey = `${this.ADMIN_PERMISSIONS_KEY}:${adminId}:${restId}`;
await this.cacheService.del(cacheKey);
}
}
@@ -8,11 +8,11 @@ export class AdminRepository extends EntityRepository<Admin> {
super(em, Admin);
}
async findByPhoneAndrestaurantSlug(phone: string, slug: string): Promise<Admin | null> {
async findByPhoneAndRestaurantSlug(phone: string, slug: string): Promise<Admin | null> {
return this.em.findOne(
Admin,
{ phone, restaurant: { slug } },
{ populate: ['restaurant', 'role', 'role.permissions'] },
{ phone, roles: { role: { restaurant: { slug } } } },
{ populate: ['roles', 'roles.role'] },
);
}
}
@@ -19,49 +19,34 @@ export interface AdminDetailResponse {
}
export class AdminDetailTransformer {
static async transform(admin: Admin): Promise<AdminDetailResponse | null> {
static transform(admin: Admin): AdminDetailResponse | null {
if (!admin) {
return null;
}
// Extract role information
const role = admin.role
? {
id: admin.role.id,
name: admin.role.name,
title: admin.role.title,
}
: null;
const role = admin.roles.getItems().find(r => r.role)?.role;
if (!role) {
return null;
}
// Extract permissions - ensure collection is loaded if needed
let permissions: string[] = [];
if (admin.role?.permissions) {
if (admin.role.permissions.isInitialized()) {
permissions = admin.role.permissions.getItems().map(p => p.name);
} else {
await admin.role.permissions.loadItems();
permissions = admin.role.permissions.getItems().map(p => p.name);
}
}
return {
id: admin.id,
firstName: admin.firstName,
lastName: admin.lastName,
phone: admin.phone,
role,
permissions,
restaurant: admin.restaurant
? {
id: admin.restaurant.id,
name: admin.restaurant.name,
slug: admin.restaurant.slug,
}
: undefined,
role: {
id: role.id,
name: role.name,
title: role.title,
},
permissions: role.permissions.getItems().map(p => p.name) || [],
restaurant: {
id: role.restaurant?.id || '',
name: role.restaurant?.name || '',
slug: role.restaurant?.slug || '',
},
};
}
}
// Extract permissions - ensure collection is loaded if needed
+3 -3
View File
@@ -13,7 +13,7 @@ import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { IAdminTokenPayload } from '../interfaces/IToken-payload';
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
import { AdminService } from 'src/modules/admin/providers/admin.service';
import { PermissionsService } from 'src/modules/roles/providers/permissions.service';
export interface AdminAuthRequest extends Request {
adminId: string;
@@ -31,7 +31,7 @@ export class AdminAuthGuard implements CanActivate {
private readonly configService: ConfigService,
@Inject(ConfigService)
private readonly adminService: AdminService,
private readonly permissionsService: PermissionsService,
private readonly reflector: Reflector,
) {}
@@ -62,7 +62,7 @@ export class AdminAuthGuard implements CanActivate {
if (!requiredPermissions || requiredPermissions.length === 0) return true;
const adminPermission = await this.adminService.getAdminPermissions(payload.adminId, payload.restId);
const adminPermission = await this.permissionsService.getAdminPermissions(payload.adminId, payload.restId);
if (!adminPermission || !Array.isArray(adminPermission)) {
this.logger.error('No permissions found');
throw new ForbiddenException('No permissions found');
+2 -2
View File
@@ -38,7 +38,7 @@ export class AuthService {
async requestOtpAdmin(dto: RequestOtpDto) {
const { phone, slug } = dto;
const admin = await this.adminRepository.findByPhoneAndrestaurantSlug(phone, slug);
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(phone, slug);
if (!admin) {
throw new NotFoundException();
}
@@ -83,7 +83,7 @@ export class AuthService {
await this.cacheService.del(`otp-admin:${slug}:${phone}`);
const admin = await this.adminRepository.findByPhoneAndrestaurantSlug(phone, slug);
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(phone, slug);
if (!admin) {
throw new BadRequestException(AuthMessage.USER_NOT_FOUND);
@@ -17,17 +17,60 @@ export interface AdminLoginResponse {
export class AdminLoginTransformer {
static async transform(admin: Admin, restaurant?: Restaurant): Promise<AdminLoginResponse> {
// Extract role name
const role = admin.role?.name || '';
// Find the AdminRole that matches the restaurant (or get the first one)
let adminRole = admin.roles.getItems().find(r => (restaurant ? r.restaurant?.id === restaurant.id : true));
// If no match found, get the first one
if (!adminRole && admin.roles.getItems().length > 0) {
adminRole = admin.roles.getItems()[0];
}
if (!adminRole) {
return {
id: admin.id,
firstName: admin.firstName,
lastName: admin.lastName,
phone: admin.phone,
role: '',
permissions: [],
restaurant: restaurant
? {
id: restaurant.id,
name: restaurant.name,
slug: restaurant.slug,
}
: undefined,
};
}
// Get the role from AdminRole
const role = adminRole.role;
if (!role) {
return {
id: admin.id,
firstName: admin.firstName,
lastName: admin.lastName,
phone: admin.phone,
role: '',
permissions: [],
restaurant: restaurant
? {
id: restaurant.id,
name: restaurant.name,
slug: restaurant.slug,
}
: undefined,
};
}
// Extract permissions - ensure collection is loaded if needed
let permissions: string[] = [];
if (admin.role?.permissions) {
if (admin.role.permissions.isInitialized()) {
permissions = admin.role.permissions.getItems().map(p => p.name);
if (role.permissions) {
if (role.permissions.isInitialized()) {
permissions = role.permissions.getItems().map(p => p.name);
} else {
await admin.role.permissions.loadItems();
permissions = admin.role.permissions.getItems().map(p => p.name);
await role.permissions.loadItems();
permissions = role.permissions.getItems().map(p => p.name);
}
}
@@ -36,7 +79,7 @@ export class AdminLoginTransformer {
firstName: admin.firstName,
lastName: admin.lastName,
phone: admin.phone,
role,
role: role.name,
permissions,
restaurant: restaurant
? {
@@ -4,6 +4,7 @@ import { BaseEntity } from '../../../common/entities/base.entity';
import { Permission } from './permission.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { RolePermission } from './rolePermission.entity';
import { AdminRole } from 'src/modules/admin/entities/adminRole.entity';
@Entity({ tableName: 'roles' })
export class Role extends BaseEntity {
@@ -18,4 +19,7 @@ export class Role extends BaseEntity {
@ManyToOne(() => Restaurant, { nullable: true })
restaurant?: Restaurant;
@OneToMany(() => AdminRole, adminRole => adminRole.role)
admins = new Collection<AdminRole>(this);
}
@@ -2,16 +2,71 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/core';
import { Permission } from '../entities/permission.entity';
import { CacheService } from 'src/modules/utils/cache.service';
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
@Injectable()
export class PermissionsService {
private readonly ADMIN_PERMISSIONS_KEY = 'admin-perms';
constructor(
@InjectRepository(Permission)
private readonly permissionRepository: EntityRepository<Permission>,
private readonly cacheService: CacheService,
private readonly adminRepository: AdminRepository,
) {}
async findAll() {
const permissions = await this.permissionRepository.findAll();
return permissions;
}
/**
* Get admin permissions from cache or database
* @param adminId - The admin ID
* @param restId - The restaurant ID
* @returns Array of permission names (string[])
*/
async getAdminPermissions(adminId: string, restId: string): Promise<string[]> {
const cacheKey = `${this.ADMIN_PERMISSIONS_KEY}:${adminId}:${restId}`;
// Try to get from cache first
const cachedPermissions = await this.cacheService.get<string>(cacheKey);
if (cachedPermissions) {
try {
const parsed: unknown = JSON.parse(cachedPermissions);
// Ensure it's an array of strings
if (Array.isArray(parsed) && parsed.every((p): p is string => typeof p === 'string')) {
return parsed;
}
// If invalid format, continue to fetch from DB
} catch {
// If parsing fails, continue to fetch from DB
}
}
// If not in cache, fetch from database
const admin = await this.adminRepository.findOne(
{ id: adminId, roles: { restaurant: { id: restId } } },
{ populate: ['roles', 'roles.role'] },
);
if (!admin || !admin.roles) {
return [];
}
// Extract permission names as array of strings
const permissions = admin.roles.map(r => r.role.permissions.getItems());
return permissions.flat().map(p => p.name);
}
/**
* Invalidate admin permissions cache
* @param adminId - The admin ID
* @param restId - The restaurant ID
*/
async invalidateAdminPermissionsCache(adminId: string, restId: string): Promise<void> {
const cacheKey = `${this.ADMIN_PERMISSIONS_KEY}:${adminId}:${restId}`;
await this.cacheService.del(cacheKey);
}
}
+3 -4
View File
@@ -7,12 +7,11 @@ import { Role } from './entities/role.entity';
import { Permission } from './entities/permission.entity';
import { RolePermission } from './entities/rolePermission.entity';
import { JwtModule } from '@nestjs/jwt';
import { UtilsModule } from '../utils/utils.module';
import { AdminModule } from '../admin/admin.module';
@Module({
imports: [
MikroOrmModule.forFeature([Role, Permission, RolePermission]),
JwtModule,
],
imports: [MikroOrmModule.forFeature([Role, Permission, RolePermission]), JwtModule, UtilsModule, AdminModule],
controllers: [RolesController],
providers: [RolesService, PermissionsService],
exports: [RolesService, PermissionsService],
+25 -3
View File
@@ -3,6 +3,7 @@ import { Seeder } from '@mikro-orm/seeder';
import { Category } from '../modules/foods/entities/category.entity';
import { Food } from '../modules/foods/entities/food.entity';
import { Admin } from '../modules/admin/entities/admin.entity';
import { AdminRole } from '../modules/admin/entities/adminRole.entity';
import { Permission as PermissionEntity } from '../modules/roles/entities/permission.entity';
import { Role } from '../modules/roles/entities/role.entity';
import { Restaurant } from '../modules/restaurants/entities/restaurant.entity';
@@ -421,10 +422,17 @@ export class DatabaseSeeder extends Seeder {
phone: '09362532122',
firstName: 'مرتضی',
lastName: 'مرتضایی',
});
em.persist(admin);
// Create AdminRole linking admin to role and restaurant
const adminRole = em.create(AdminRole, {
admin,
role: restaurantRole,
restaurant: zhivan,
});
em.persist(admin);
em.persist(adminRole);
admin.roles.add(adminRole);
}
const adminHamid = await em.findOne(Admin, { phone: '09185290775' });
@@ -433,10 +441,17 @@ export class DatabaseSeeder extends Seeder {
phone: '09185290775',
firstName: 'حمید',
lastName: 'زرگامی',
});
em.persist(admin);
// Create AdminRole linking admin to role and restaurant
const adminRole = em.create(AdminRole, {
admin,
role: accountant,
restaurant: boote,
});
em.persist(admin);
em.persist(adminRole);
admin.roles.add(adminRole);
}
const adminMehrdad = await em.findOne(Admin, { phone: '0912928395' });
@@ -445,10 +460,17 @@ export class DatabaseSeeder extends Seeder {
phone: '0912928395',
firstName: 'مهرداد',
lastName: 'مظفری',
});
em.persist(admin);
// Create AdminRole linking admin to role (no restaurant for superAdmin)
const adminRole = em.create(AdminRole, {
admin,
role: superAdmin,
restaurant: null, // SuperAdmin doesn't belong to a specific restaurant
});
em.persist(admin);
em.persist(adminRole);
admin.roles.add(adminRole);
}
await em.flush();