From 440c3721ce4518e652c2e58f330cf7df54323989 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Wed, 12 Nov 2025 09:28:56 +0330 Subject: [PATCH] role perms --- src/common/enums/message.enum.ts | 2 + .../middleware/http-exception.filter.ts | 33 --------- src/core/exceptions/http.exceptions.ts | 36 ++++++++++ .../exceptions}/response.interceptor.ts | 0 .../interceptors/pagination.interceptor.ts | 61 ++++++++++++++++ src/core/interceptors/response.interceptor.ts | 30 ++++++++ src/core/middlewares/logger.middleware.ts | 26 +++++++ src/main.ts | 9 +++ src/modules/admin/admin.module.ts | 10 ++- src/modules/admin/entities/admin.entity.ts | 15 +++- .../admin/entities/permission.entity.ts | 13 ++++ src/modules/admin/entities/role.entity.ts | 22 ++++++ .../admin/entities/rolePermission.entity.ts | 17 +++++ .../admin/repositories/rest.repository.ts | 14 ++++ src/modules/auth/auth.module.ts | 2 +- .../auth/controllers/admin-auth.controller.ts | 58 +++++++++++++++ .../auth/{ => controllers}/auth.controller.ts | 24 +++---- src/modules/auth/dto/request-otp-admin.dto.ts | 10 +++ src/modules/auth/dto/request-otp.dto.ts | 2 +- src/modules/auth/dto/verify-otp.dto copy.ts | 2 +- src/modules/auth/guards/auth.guard.ts | 8 +-- src/modules/auth/interfaces/IToken-payload.ts | 7 +- src/modules/auth/services/auth.service.ts | 71 +++++++++++++++---- src/modules/auth/services/tokens.service.ts | 57 ++++++++++++--- .../repositories/rest.repository.ts | 14 ++-- .../restaurants/restaurants.service.ts | 10 ++- .../users/entities/refresh-token.entity.ts | 4 ++ .../users/repositories/user.repository.ts | 12 +++- src/modules/users/user.controller.ts | 3 + src/modules/users/user.module.ts | 5 +- src/modules/users/user.service.ts | 9 ++- 31 files changed, 488 insertions(+), 98 deletions(-) delete mode 100644 src/common/middleware/http-exception.filter.ts create mode 100755 src/core/exceptions/http.exceptions.ts rename src/{common/middleware => core/exceptions}/response.interceptor.ts (100%) create mode 100755 src/core/interceptors/pagination.interceptor.ts create mode 100755 src/core/interceptors/response.interceptor.ts create mode 100755 src/core/middlewares/logger.middleware.ts create mode 100755 src/modules/admin/entities/permission.entity.ts create mode 100755 src/modules/admin/entities/role.entity.ts create mode 100644 src/modules/admin/entities/rolePermission.entity.ts create mode 100644 src/modules/admin/repositories/rest.repository.ts create mode 100644 src/modules/auth/controllers/admin-auth.controller.ts rename src/modules/auth/{ => controllers}/auth.controller.ts (75%) create mode 100644 src/modules/auth/dto/request-otp-admin.dto.ts diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 75f0b39..21f753d 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -19,6 +19,7 @@ export const enum AuthMessage { INVALID_EMAIL_FORMAT = 'فرمت ایمیل صحیح نیست', PasswordNotEmpty = 'پسورد نمیتواند خالی باشد.', USER_NOT_FOUND = 'کاربری با این شماره وجود ندارد', + ADMIN_NOT_FOUND = 'ادمینی با این شماره وجود ندارد', USER_EXISTS = 'با این شماره قبلا ثبت نام شده است', USER_REGISTER_SUCCESS = 'ثبت نام موفقیت آمیز بود', INVALID_USER = 'کاربری با این مشخصات یافت نشد', @@ -69,6 +70,7 @@ export const enum AuthMessage { export const enum UserMessage { USER_NOT_FOUND = 'کاربری با این مشخصات یافت نشد', + Rest_NOT_FOUND = 'رستورانی با این مشخصات یافت نشد', USER_EXISTS = 'با این شماره قبلا ثبت نام شده است', USER_REGISTER_SUCCESS = 'ثبت نام موفقیت آمیز بود', EMAIL_ADDRESS_ALREADY_EXISTS = 'ایمیل قبلا ثبت شده است', diff --git a/src/common/middleware/http-exception.filter.ts b/src/common/middleware/http-exception.filter.ts deleted file mode 100644 index 9c8c943..0000000 --- a/src/common/middleware/http-exception.filter.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; -import { Request, Response } from 'express'; - -@Catch(HttpException) -export class HttpErrorFilter implements ExceptionFilter { - catch(exception: HttpException, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - - const status = exception.getStatus(); - - const exceptionResponse = exception.getResponse(); - - let messages: (string | object)[] = []; - if (typeof exceptionResponse === 'string') { - messages = [exceptionResponse]; - } else if (typeof exceptionResponse === 'object' && exceptionResponse !== null && 'message' in exceptionResponse) { - messages = Array.isArray(exceptionResponse.message) - ? (exceptionResponse.message as string[]) - : [exceptionResponse.message as string]; - } else messages = ['Unexpected error']; - - const errorResponse = { - success: false, - timestamp: new Date().toISOString(), - path: request.url, - messages, - }; - - response.status(status).json(errorResponse); - } -} diff --git a/src/core/exceptions/http.exceptions.ts b/src/core/exceptions/http.exceptions.ts new file mode 100755 index 0000000..cce8194 --- /dev/null +++ b/src/core/exceptions/http.exceptions.ts @@ -0,0 +1,36 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; +import { FastifyReply } from 'fastify'; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const reply = ctx.getResponse(); + // const request = ctx.getRequest(); + const status = exception.getStatus() || HttpStatus.INTERNAL_SERVER_ERROR; + + const exceptionResponse = exception.getResponse(); + const message = this.extractMessage(exceptionResponse); + + reply.status(status).send({ + statusCode: status, + success: false, + error: { + message, + // timestamp: new Date().toISOString(), + // path: request.url, + }, + }); + } + + private extractMessage(response: string | Record): string | string[] { + if (typeof response === 'string') { + return [response]; + } + if (typeof response === 'object' && 'message' in response) { + return Array.isArray(response.message) ? response.message : [response.message]; + } + + return 'something wrong'; + } +} diff --git a/src/common/middleware/response.interceptor.ts b/src/core/exceptions/response.interceptor.ts similarity index 100% rename from src/common/middleware/response.interceptor.ts rename to src/core/exceptions/response.interceptor.ts diff --git a/src/core/interceptors/pagination.interceptor.ts b/src/core/interceptors/pagination.interceptor.ts new file mode 100755 index 0000000..84b169f --- /dev/null +++ b/src/core/interceptors/pagination.interceptor.ts @@ -0,0 +1,61 @@ +// import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; +// import { FastifyRequest } from 'fastify'; +// import { Observable, map } from 'rxjs'; + +// import { IPageFormat } from '../../../../danak_dsc_api/src/common/interfaces/IPagination'; + +// @Injectable() +// export class PaginationInterceptor implements NestInterceptor { +// private readonly logger = new Logger(PaginationInterceptor.name); + +// intercept(context: ExecutionContext, next: CallHandler): Observable { +// const request = context.switchToHttp().getRequest(); +// const query = request.query as { page: string; limit: string }; + +// const page = parseInt(query.page, 10) || 1; +// const limit = parseInt(query.limit, 10) || 10; + +// const className = context.getClass().name; +// const handlerName = context.getHandler().name; + +// return next.handle().pipe( +// map(data => { +// if (data && (data.paginate || data.count)) { +// const { count, paginate, ...response } = data; +// this.logger.log(`paginate response from ${className}.${handlerName}`); +// const pager = this.formatPage(page, limit, count, request); + +// return { +// pager, +// ...response, +// }; +// } + +// return data; +// }), +// ); +// } + +// private formatPage(page: number, limit: number, totalItems: number, request: FastifyRequest): IPageFormat { +// const totalPages = Math.ceil(totalItems / limit); +// const prevPage = page === 1 ? false : page - 1; +// const nextPage = page >= totalPages ? false : page + 1; + +// const formatLink = (pageNum: number | boolean): string | boolean => { +// if (!pageNum) return false; +// const protocol = request.protocol; +// const hostname = request.hostname; +// const originalUrl = request.url; +// return `${protocol}://${hostname}${originalUrl.split('?')[0]}?page=${pageNum}&limit=${limit}`; +// }; + +// return { +// page, +// limit, +// totalItems, +// totalPages, +// prevPage: formatLink(prevPage), +// nextPage: formatLink(nextPage), +// }; +// } +// } diff --git a/src/core/interceptors/response.interceptor.ts b/src/core/interceptors/response.interceptor.ts new file mode 100755 index 0000000..5748cfa --- /dev/null +++ b/src/core/interceptors/response.interceptor.ts @@ -0,0 +1,30 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { FastifyReply } from 'fastify'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class ResponseInterceptor implements NestInterceptor { + // + intercept(context: ExecutionContext, next: CallHandler>): Observable { + const ctx = context.switchToHttp().getResponse(); + const statusCode = ctx.statusCode; + + return next.handle().pipe( + map(data => { + if (data && data.data !== undefined) { + return { + statusCode, + success: true, + data: data.data, + }; + } + return { + statusCode, + success: true, + data, + }; + }), + ); + } +} diff --git a/src/core/middlewares/logger.middleware.ts b/src/core/middlewares/logger.middleware.ts new file mode 100755 index 0000000..7234e54 --- /dev/null +++ b/src/core/middlewares/logger.middleware.ts @@ -0,0 +1,26 @@ +import { Injectable, Logger, NestMiddleware } from "@nestjs/common"; +import { FastifyReply, FastifyRequest } from "fastify"; + +@Injectable() +export class HTTPLogger implements NestMiddleware { + private readonly logger = new Logger(HTTPLogger.name); + // use(req: any, res: any, next: (error?: Error | any) => void) {} + + use(req: FastifyRequest, rep: FastifyReply["raw"], next: () => void) { + const { ip, method, originalUrl } = req; + const userAgent = req.headers["user-agent"] || ""; + const startAt = process.hrtime(); + + rep.on("finish", () => { + const { statusCode } = rep; + const contentLength = rep.getHeader("content-length") || 0; + const dif = process.hrtime(startAt); + const responseTime = dif[0] * 1e3 + dif[1] * 1e-6; + this.logger.log( + `${method} - ${originalUrl} - ${statusCode} - ${contentLength} - ${userAgent} - ${ip} - ${responseTime.toFixed(2)}ms`, + ); + }); + + next(); + } +} diff --git a/src/main.ts b/src/main.ts index fdcd80b..d79d2a5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,9 @@ import multipart from '@fastify/multipart'; // 👈 Import the Fastify application type import { type NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify'; +import { HttpExceptionFilter } from './core/exceptions/http.exceptions'; +import { ResponseInterceptor } from './core/interceptors/response.interceptor'; +// import { PaginationInterceptor } from './core/interceptors/pagination.interceptor'; async function bootstrap() { const logger = new Logger('APP'); @@ -22,6 +25,12 @@ async function bootstrap() { await app.register(multipart); app.useGlobalPipes(new ValidationPipe()); + app.useGlobalInterceptors( + new ResponseInterceptor(), + // , new PaginationInterceptor() + ); + app.useGlobalFilters(new HttpExceptionFilter()); + const configService = app.get(ConfigService); // Ensure the port is handled correctly, use 4000 as fallback if needed const APP_PORT = configService.getOrThrow('APP_PORT') ?? 4000; diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 5a245df..f0425a8 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -3,11 +3,15 @@ import { AdminService } from './admin.service'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Admin } from './entities/admin.entity'; import { JwtModule } from '@nestjs/jwt'; +import { AdminRepository } from './repositories/rest.repository'; +import { Role } from './entities/role.entity'; +import { Permission } from './entities/permission.entity'; +import { RolePermission } from './entities/rolePermission.entity'; @Module({ - providers: [AdminService], + providers: [AdminService, AdminRepository], controllers: [], - imports: [MikroOrmModule.forFeature([Admin]), JwtModule], - exports: [AdminService], + imports: [MikroOrmModule.forFeature([Admin, Role, Permission, RolePermission]), JwtModule], + exports: [AdminService, AdminRepository], }) export class AdminModule {} diff --git a/src/modules/admin/entities/admin.entity.ts b/src/modules/admin/entities/admin.entity.ts index 975ba25..ab519ec 100644 --- a/src/modules/admin/entities/admin.entity.ts +++ b/src/modules/admin/entities/admin.entity.ts @@ -1,7 +1,11 @@ -import { Entity, Property } from '@mikro-orm/core'; +import { Entity, ManyToOne, OneToOne, Property, Unique } from '@mikro-orm/core'; import { BaseEntity } from '../../../common/entities/base.entity'; +import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity'; +import { Role } from './role.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; @@ -9,6 +13,13 @@ export class Admin extends BaseEntity { @Property({ nullable: true }) lastName?: string; - @Property({ unique: true }) + @Property() phone!: string; + + // Add the new role property + @ManyToOne(() => Role) + role!: Role; + + @OneToOne(() => Restaurant, { owner: true }) + restaurant!: Restaurant; } diff --git a/src/modules/admin/entities/permission.entity.ts b/src/modules/admin/entities/permission.entity.ts new file mode 100755 index 0000000..463de64 --- /dev/null +++ b/src/modules/admin/entities/permission.entity.ts @@ -0,0 +1,13 @@ +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; + + @ManyToMany({ entity: () => Role, mappedBy: r => r.permissions }) + roles = new Collection(this); +} diff --git a/src/modules/admin/entities/role.entity.ts b/src/modules/admin/entities/role.entity.ts new file mode 100755 index 0000000..8841a03 --- /dev/null +++ b/src/modules/admin/entities/role.entity.ts @@ -0,0 +1,22 @@ +// role.entity.ts +import { Collection, Entity, ManyToMany, OneToMany, OneToOne, 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 'src/modules/restaurants/entities/restaurant.entity'; +import { RolePermission } from './rolePermission.entity'; + +@Entity() +export class Role extends BaseEntity { + @Property() + name!: string; + + @ManyToMany({ entity: () => Permission, pivotEntity: () => RolePermission, inversedBy: p => p.roles }) + permissions = new Collection(this); + + @OneToMany(() => Admin, admin => admin.role) + admins = new Collection(this); + + @OneToOne(() => Restaurant, { owner: true, nullable: true }) + restaurant?: Restaurant; +} diff --git a/src/modules/admin/entities/rolePermission.entity.ts b/src/modules/admin/entities/rolePermission.entity.ts new file mode 100644 index 0000000..75d062d --- /dev/null +++ b/src/modules/admin/entities/rolePermission.entity.ts @@ -0,0 +1,17 @@ +// 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 خودداری کن مگر بخوای نام/نوع ستون را سفارشی کنی. +} diff --git a/src/modules/admin/repositories/rest.repository.ts b/src/modules/admin/repositories/rest.repository.ts new file mode 100644 index 0000000..a95a00b --- /dev/null +++ b/src/modules/admin/repositories/rest.repository.ts @@ -0,0 +1,14 @@ +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { Admin } from '../entities/admin.entity'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AdminRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, Admin); + } + + async findByPhoneAndrestaurantSlug(phone: string, slug: string): Promise { + return this.findOne({ phone, restaurant: { slug } }, { populate: ['restaurant', 'role', 'role.permissions'] }); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index f3c21c1..c04319d 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { AuthService } from './services/auth.service'; -import { AuthController } from './auth.controller'; +import { AuthController } from './controllers/auth.controller'; import { UtilsModule } from '../utils/utils.module'; import { UserModule } from '../users/user.module'; import { JwtModule } from '@nestjs/jwt'; diff --git a/src/modules/auth/controllers/admin-auth.controller.ts b/src/modules/auth/controllers/admin-auth.controller.ts new file mode 100644 index 0000000..a302084 --- /dev/null +++ b/src/modules/auth/controllers/admin-auth.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Post, Body } from '@nestjs/common'; +import { AuthService } from '../services/auth.service'; +import { RequestOtpDto } from '../dto/request-otp.dto'; +import { ApiTags, ApiOperation, ApiBody, ApiResponse } from '@nestjs/swagger'; +import { VerifyOtpDto } from '../dto/verify-otp.dto copy'; +import { Throttle } from '@nestjs/throttler'; +import { RefreshTokenRateLimit } from 'src/common/decorators/rate-limit.decorator'; +import { RefreshTokenDto } from '../dto/refresh-token.dto'; + +@ApiTags('admin/auth') +@Controller('admin/auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Throttle({ default: { limit: 3, ttl: 180_000 } }) + @Post('otp/request') + @ApiOperation({ summary: 'Request OTP for login or signup' }) + @ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' }) + @ApiResponse({ status: 201, description: 'OTP requested successfully' }) + @ApiResponse({ status: 400, description: 'Invalid mobile number' }) + otpRequest(@Body() dto: RequestOtpDto) { + return this.authService.requestOtp(dto); + } + + @Post('otp/verify') + @ApiOperation({ summary: 'Verify OTP code' }) + @ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' }) + @ApiResponse({ status: 200, description: 'OTP verified successfully' }) + @ApiResponse({ status: 400, description: 'Invalid OTP or expired' }) + otpVerify(@Body() dto: VerifyOtpDto) { + return this.authService.verifyOtp(dto.phone, dto.slug, dto.otp); // assuming dto has `otp` property + } + + // @Post('admin/otp/request') + // @ApiOperation({ summary: 'Request OTP for login or signup' }) + // @ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' }) + // @ApiResponse({ status: 201, description: 'OTP requested successfully' }) + // @ApiResponse({ status: 400, description: 'Invalid mobile number' }) + // adminOtpRequest(@Body() dto: RequestOtpDto) { + // return this.authService.requestOtpAdmin(dto); + // } + + // @Post('admin/otp/verify') + // @ApiOperation({ summary: 'Verify OTP code' }) + // @ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' }) + // @ApiResponse({ status: 200, description: 'OTP verified successfully' }) + // @ApiResponse({ status: 400, description: 'Invalid OTP or expired' }) + // adminOtpVerify(@Body() dto: VerifyOtpDto) { + // return this.authService.verifyOtpAdmin(dto.phone, dto.otp); // assuming dto has `otp` property + // } + + @RefreshTokenRateLimit() + @ApiOperation({ summary: 'refresh the user access token / refresh token' }) + @Post('refresh') + refreshToken(@Body() refreshTokenDto: RefreshTokenDto) { + return this.authService.refreshToken(refreshTokenDto.refreshToken); + } +} diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/controllers/auth.controller.ts similarity index 75% rename from src/modules/auth/auth.controller.ts rename to src/modules/auth/controllers/auth.controller.ts index 0d38651..b8e2ca8 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/controllers/auth.controller.ts @@ -1,11 +1,11 @@ import { Controller, Post, Body } from '@nestjs/common'; -import { AuthService } from './services/auth.service'; -import { RequestOtpDto } from './dto/request-otp.dto'; +import { AuthService } from '../services/auth.service'; +import { RequestOtpDto } from '../dto/request-otp.dto'; import { ApiTags, ApiOperation, ApiBody, ApiResponse } from '@nestjs/swagger'; -import { VerifyOtpDto } from './dto/verify-otp.dto copy'; +import { VerifyOtpDto } from '../dto/verify-otp.dto copy'; import { Throttle } from '@nestjs/throttler'; import { RefreshTokenRateLimit } from 'src/common/decorators/rate-limit.decorator'; -import { RefreshTokenDto } from './dto/refresh-token.dto'; +import { RefreshTokenDto } from '../dto/refresh-token.dto'; @ApiTags('auth') @Controller('auth') @@ -31,14 +31,14 @@ export class AuthController { return this.authService.verifyOtp(dto.phone, dto.slug, dto.otp); // assuming dto has `otp` property } - @Post('admin/otp/request') - @ApiOperation({ summary: 'Request OTP for login or signup' }) - @ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' }) - @ApiResponse({ status: 201, description: 'OTP requested successfully' }) - @ApiResponse({ status: 400, description: 'Invalid mobile number' }) - adminOtpRequest(@Body() dto: RequestOtpDto) { - return this.authService.requestOtpAdmin(dto); - } + // @Post('admin/otp/request') + // @ApiOperation({ summary: 'Request OTP for login or signup' }) + // @ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' }) + // @ApiResponse({ status: 201, description: 'OTP requested successfully' }) + // @ApiResponse({ status: 400, description: 'Invalid mobile number' }) + // adminOtpRequest(@Body() dto: RequestOtpDto) { + // return this.authService.requestOtpAdmin(dto); + // } // @Post('admin/otp/verify') // @ApiOperation({ summary: 'Verify OTP code' }) diff --git a/src/modules/auth/dto/request-otp-admin.dto.ts b/src/modules/auth/dto/request-otp-admin.dto.ts new file mode 100644 index 0000000..8ffba53 --- /dev/null +++ b/src/modules/auth/dto/request-otp-admin.dto.ts @@ -0,0 +1,10 @@ +import { IsNotEmpty, IsString, IsMobilePhone } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RequestOtpDto { + @IsNotEmpty() + @IsString() + @ApiProperty({ example: '09362532122', description: 'Mobile number' }) + @IsMobilePhone('fa-IR') + phone: string; +} diff --git a/src/modules/auth/dto/request-otp.dto.ts b/src/modules/auth/dto/request-otp.dto.ts index e55e07f..1c7e033 100644 --- a/src/modules/auth/dto/request-otp.dto.ts +++ b/src/modules/auth/dto/request-otp.dto.ts @@ -13,6 +13,6 @@ export class RequestOtpDto { @IsNotEmpty() @IsString() - @ApiProperty({ example: '', description: 'Restuarant slug' }) + @ApiProperty({ example: '', description: 'restaurant slug' }) slug: string; } diff --git a/src/modules/auth/dto/verify-otp.dto copy.ts b/src/modules/auth/dto/verify-otp.dto copy.ts index 3cc7995..cf62671 100644 --- a/src/modules/auth/dto/verify-otp.dto copy.ts +++ b/src/modules/auth/dto/verify-otp.dto copy.ts @@ -19,6 +19,6 @@ export class VerifyOtpDto { @IsNotEmpty() @IsString() - @ApiProperty({ example: '', description: 'Restuarant slug' }) + @ApiProperty({ example: '', description: 'restaurant slug' }) slug: string; } diff --git a/src/modules/auth/guards/auth.guard.ts b/src/modules/auth/guards/auth.guard.ts index 7b2dc40..e4a56bb 100644 --- a/src/modules/auth/guards/auth.guard.ts +++ b/src/modules/auth/guards/auth.guard.ts @@ -6,7 +6,7 @@ import { ITokenPayload } from '../interfaces/IToken-payload'; export interface AuthRequest extends Request { userId: string; - slug: string; + restId: string; } @Injectable() @@ -30,9 +30,9 @@ export class AuthGuard implements CanActivate { const payload = await this.jwtService.verifyAsync(token, { secret, }); - - request['userId'] = payload.id; - request['slug'] = payload.restId; + console.log('payload', payload); + request['userId'] = payload.userId; + request['restId'] = payload.restId; } catch { throw new UnauthorizedException(); } diff --git a/src/modules/auth/interfaces/IToken-payload.ts b/src/modules/auth/interfaces/IToken-payload.ts index bbd7b1b..5603e41 100755 --- a/src/modules/auth/interfaces/IToken-payload.ts +++ b/src/modules/auth/interfaces/IToken-payload.ts @@ -1,4 +1,9 @@ export interface ITokenPayload { - id: string; + userId: string; + restId: string; +} + +export interface IAdminTokenPayload { + adminId: string; restId: string; } diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 67e2e1d..cf588be 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -4,21 +4,22 @@ import { CacheService } from '../../utils/cache.service'; import { SmsService } from '../../utils/sms.service'; import { UserService } from '../../users/user.service'; import { randomInt } from 'crypto'; -import { JwtService } from '@nestjs/jwt'; -import { AdminService } from '../../admin/admin.service'; import { TokensService } from './tokens.service'; import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository'; -import { RestMessage } from 'src/common/enums/message.enum'; +import { AuthMessage, RestMessage } from 'src/common/enums/message.enum'; +import { AdminRepository } from 'src/modules/admin/repositories/rest.repository'; @Injectable() export class AuthService { + readonly ADMIN_PERMISSIONS_KEY = 'admin-perms'; + constructor( private readonly cacheService: CacheService, private readonly smsService: SmsService, private readonly userService: UserService, - private readonly adminService: AdminService, private readonly tokensService: TokensService, private readonly restRepository: RestRepository, + private readonly adminRepository: AdminRepository, ) {} async requestOtp(dto: RequestOtpDto) { @@ -27,26 +28,26 @@ export class AuthService { await this.cacheService.set(`otp:${slug}:${phone}`, code, 160); - await this.smsService.sendOtp(phone, code); + // await this.smsService.sendOtp(phone, code); - return { success: true, message: ' OTP sent successfully' }; + return { code }; } async requestOtpAdmin(dto: RequestOtpDto) { - const { phone } = dto; + const { phone, slug } = dto; - const admin = await this.adminService.findByPhone(phone); + const admin = await this.adminRepository.find({ phone }); if (!admin) { throw new NotFoundException(); } const code = this.generateOtpCode(); - await this.cacheService.set(`otp-admin:${phone}`, code, 160); + await this.cacheService.set(`otp-admin:${slug}:${phone}`, code, 160); await this.smsService.sendOtp(phone, code); - return { success: true, message: ' OTP sent successfully' }; + return true; } async verifyOtp(phone: string, slug: string, code: string) { @@ -57,14 +58,46 @@ export class AuthService { await this.cacheService.del(`otp:${slug}:${phone}`); const user = await this.userService.findOrCreateByPhone(phone); + const rest = await this.restRepository.findOne({ slug }); + if (!rest) { throw new BadRequestException(RestMessage.NOT_FOUND); } - // const accessToken = await this.issueToken(user.id, AuthEntityType.USER); + const tokens = await this.tokensService.generateTokens(user, rest); - return { success: true, message: 'User registered successfully!', tokens }; + return { tokens, user }; + } + + async verifyOtpAdmin(phone: string, slug: string, code: string) { + const cachedCode = await this.cacheService.get(`otp:${slug}:${phone}`); + if (!cachedCode) throw new BadRequestException('OTP expired or not found'); + if (cachedCode !== code) throw new BadRequestException('Invalid OTP'); + + await this.cacheService.del(`otp-admin:${slug}:${phone}`); + + const admin = await this.adminRepository.findByPhoneAndrestaurantSlug(phone, slug); + + if (!admin) { + throw new BadRequestException(AuthMessage.USER_NOT_FOUND); + } + + const rest = await this.restRepository.findOne({ slug }); + + if (!rest) { + throw new BadRequestException(RestMessage.NOT_FOUND); + } + + const tokens = await this.tokensService.generateTokensAdmin(admin, rest); + + await this.cacheService.set( + `${this.ADMIN_PERMISSIONS_KEY}:${admin.id}:${rest.id}`, + JSON.stringify(admin.role.permissions), + 3600, + ); + + return { tokens, admin }; } private generateOtpCode(): string { @@ -75,4 +108,18 @@ export class AuthService { refreshToken(oldRefreshToken: string) { return this.tokensService.refreshToken(oldRefreshToken); } + + // async getAdminPerms(adminId: string, restId: string) { + // const cachedPerms = await this.cacheService.get(`${this.ADMIN_PERMISSIONS_KEY}:${adminId}:${restId}`); + // // eslint-disable-next-line @typescript-eslint/no-unsafe-return + // return cachedPerms ? JSON.parse(cachedPerms) : []; + // } + + async setAdminPerms(adminId: string, restId: string, permissions: string) { + return this.cacheService.set( + `${this.ADMIN_PERMISSIONS_KEY}:${adminId}:${restId}`, + JSON.stringify(permissions), + 3600, + ); + } } diff --git a/src/modules/auth/services/tokens.service.ts b/src/modules/auth/services/tokens.service.ts index 27ccf67..e3fba93 100755 --- a/src/modules/auth/services/tokens.service.ts +++ b/src/modules/auth/services/tokens.service.ts @@ -9,9 +9,11 @@ import dayjs from 'dayjs'; import { AuthMessage, UserMessage } from '../../../common/enums/message.enum'; import { RefreshToken } from '../../users/entities/refresh-token.entity'; import { User } from '../../users/entities/user.entity'; -import { ITokenPayload } from '../interfaces/IToken-payload'; +import { IAdminTokenPayload, ITokenPayload } from '../interfaces/IToken-payload'; import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity'; import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository'; +import { Admin } from 'src/modules/admin/entities/admin.entity'; +import { CacheService } from 'src/modules/utils/cache.service'; @Injectable() export class TokensService { @@ -21,12 +23,23 @@ export class TokensService { private readonly jwtService: JwtService, private readonly em: EntityManager, private readonly restRepository: RestRepository, + private readonly cacheService: CacheService, ) {} async generateTokens(user: User, rest: Restaurant, em?: EntityManager) { return this.generateAccessAndRefreshToken( { - id: user.id, + userId: user.id, + restId: rest.id, + }, + em, + ); + } + + async generateTokensAdmin(admin: Admin, rest: Restaurant, em?: EntityManager) { + return this.generateAccessAndRefreshTokenAdmin( + { + adminId: admin.id, restId: rest.id, }, em, @@ -38,13 +51,13 @@ export class TokensService { const refreshExpire = this.configService.getOrThrow('REFRESH_TOKEN_EXPIRE'); const tokenPayload: ITokenPayload = { - id: payload.id, + userId: payload.userId, restId: payload.restId, }; const accessToken = await this.jwtService.signAsync(tokenPayload, { expiresIn: `${accessExpire}m` }); const refreshToken = this.generateRefreshToken(); - await this.storeRefreshToken(payload.id, refreshToken, em); + await this.storeRefreshToken(payload.userId, payload.restId, refreshToken, em); return { accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'minute').valueOf() }, @@ -52,7 +65,25 @@ export class TokensService { }; } - async storeRefreshToken(userId: string, refreshToken: string, em?: EntityManager) { + private async generateAccessAndRefreshTokenAdmin(payload: IAdminTokenPayload, em?: EntityManager) { + const accessExpire = this.configService.getOrThrow('ACCESS_TOKEN_EXPIRE'); + const refreshExpire = this.configService.getOrThrow('REFRESH_TOKEN_EXPIRE'); + + const tokenPayload: IAdminTokenPayload = { + adminId: payload.adminId, + restId: payload.restId, + }; + const accessToken = await this.jwtService.signAsync(tokenPayload, { expiresIn: `${accessExpire}m` }); + const refreshToken = this.generateRefreshToken(); + + await this.storeRefreshToken(payload.adminId, payload.restId, refreshToken, em); + return { + accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'minute').valueOf() }, + refreshToken: { token: refreshToken, expire: dayjs().add(refreshExpire, 'day').valueOf() }, + }; + } + + async storeRefreshToken(userId: string, restId: string, refreshToken: string, em?: EntityManager) { const entityManager = em || this.em.fork(); const isExternalTransaction = !!em && em.isInTransaction(); @@ -68,15 +99,19 @@ export class TokensService { const user = await entityManager.findOne(User, { id: userId }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + const restaurant = await entityManager.findOne(Restaurant, { id: restId }); + if (!restaurant) throw new BadRequestException(UserMessage.Rest_NOT_FOUND); + const token = entityManager.create(RefreshToken, { token: refreshToken, + restaurant, user, expiresAt, }); // Use persist instead of persistAndFlush when in transaction if (isExternalTransaction) { - await entityManager.persist(token); + entityManager.persist(token); } else { await entityManager.persistAndFlush(token); if (entityManager.isInTransaction()) { @@ -98,19 +133,19 @@ export class TokensService { try { await entityManager.begin(); - + console.log('oldRefreshToken', oldRefreshToken); const token = await entityManager.findOne( RefreshToken, { token: oldRefreshToken }, { - populate: ['user'], + populate: ['user', 'restaurant'], }, ); if (!token) throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN); - const rest = await this.restRepository.findOne({ id: token.user.id }); - if (!rest) throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN); + // const rest = await this.restRepository.findOne({ id: token.user.id }); + // if (!rest) throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN); if (dayjs(token.expiresAt).isBefore(dayjs())) { entityManager.remove(token); @@ -123,7 +158,7 @@ export class TokensService { entityManager.remove(token); // Generate new tokens within the same transaction - const tokens = await this.generateTokens(token.user, rest, entityManager); + const tokens = await this.generateTokens(token.user, token.restaurant, entityManager); // Commit the transaction await entityManager.flush(); diff --git a/src/modules/restaurants/repositories/rest.repository.ts b/src/modules/restaurants/repositories/rest.repository.ts index dc7cb7c..8e53dd6 100644 --- a/src/modules/restaurants/repositories/rest.repository.ts +++ b/src/modules/restaurants/repositories/rest.repository.ts @@ -1,6 +1,10 @@ -import { EntityRepository } from '@mikro-orm/postgresql'; -import type { Restaurant } from '../entities/restaurant.entity'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { Restaurant } from '../entities/restaurant.entity'; +import { Injectable } from '@nestjs/common'; -// import { PaginationUtils } from '../../utils/services/pagination.utils'; - -export class RestRepository extends EntityRepository {} +@Injectable() +export class RestRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, Restaurant); + } +} diff --git a/src/modules/restaurants/restaurants.service.ts b/src/modules/restaurants/restaurants.service.ts index 6fed433..bcb488a 100644 --- a/src/modules/restaurants/restaurants.service.ts +++ b/src/modules/restaurants/restaurants.service.ts @@ -3,10 +3,16 @@ import { CreateRestaurantDto } from './dto/create-restaurant.dto'; import { UpdateRestaurantDto } from './dto/update-restaurant.dto'; import { Restaurant } from './entities/restaurant.entity'; import { EntityManager } from '@mikro-orm/postgresql'; +import { RestRepository } from './repositories/rest.repository'; +import { InjectRepository } from '@mikro-orm/nestjs'; @Injectable() export class RestaurantsService { - constructor(private readonly em: EntityManager) {} + constructor( + private readonly em: EntityManager, + @InjectRepository(Restaurant) + private readonly restRepository: RestRepository, + ) {} async create(dto: CreateRestaurantDto): Promise { const restaurant = this.em.create(Restaurant, { @@ -19,7 +25,7 @@ export class RestaurantsService { } findAll() { - return `This action returns all restaurants`; + return this.restRepository.findAll(); } async findBySlug(slug: string): Promise { diff --git a/src/modules/users/entities/refresh-token.entity.ts b/src/modules/users/entities/refresh-token.entity.ts index 7105742..b246d9f 100644 --- a/src/modules/users/entities/refresh-token.entity.ts +++ b/src/modules/users/entities/refresh-token.entity.ts @@ -2,6 +2,7 @@ import { Entity, ManyToOne, Opt, Property } from '@mikro-orm/core'; import { User } from './user.entity'; import { BaseEntity } from '../../../common/entities/base.entity'; +import { Restaurant } from '../../../modules/restaurants/entities/restaurant.entity'; @Entity({ tableName: 'refreshtokens' }) export class RefreshToken extends BaseEntity { @@ -11,6 +12,9 @@ export class RefreshToken extends BaseEntity { @ManyToOne(() => User, { deleteRule: 'cascade' }) user!: User; + @ManyToOne(() => Restaurant, { deleteRule: 'cascade' }) + restaurant!: Restaurant; + @Property({ type: 'timestamptz' }) expiresAt!: Date; diff --git a/src/modules/users/repositories/user.repository.ts b/src/modules/users/repositories/user.repository.ts index 07f7910..07516dc 100644 --- a/src/modules/users/repositories/user.repository.ts +++ b/src/modules/users/repositories/user.repository.ts @@ -1,4 +1,10 @@ -import { EntityRepository } from '@mikro-orm/postgresql'; -import type { User } from '../entities/user.entity'; +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { User } from '../entities/user.entity'; -export class UserRepository extends EntityRepository {} +@Injectable() +export class UserRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, User); + } +} diff --git a/src/modules/users/user.controller.ts b/src/modules/users/user.controller.ts index d36a78a..8cd41c2 100644 --- a/src/modules/users/user.controller.ts +++ b/src/modules/users/user.controller.ts @@ -15,10 +15,13 @@ export class UserController { @Get('/me') async getUser(@Req() req: AuthRequest) { const userId = req.userId; + const restId = req.restId; const user = await this.userService.findById(userId); return { message: `GET request: Retrieving profile for authenticated user `, user, + userId, + restId, }; } diff --git a/src/modules/users/user.module.ts b/src/modules/users/user.module.ts index bdbbb19..f5f8cd9 100644 --- a/src/modules/users/user.module.ts +++ b/src/modules/users/user.module.ts @@ -4,11 +4,12 @@ import { UserController } from './user.controller'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { User } from './entities/user.entity'; import { JwtModule } from '@nestjs/jwt'; +import { UserRepository } from './repositories/user.repository'; @Module({ - providers: [UserService], + providers: [UserService, UserRepository], controllers: [UserController], imports: [MikroOrmModule.forFeature([User]), JwtModule], - exports: [UserService], + exports: [UserService, UserRepository], }) export class UserModule {} diff --git a/src/modules/users/user.service.ts b/src/modules/users/user.service.ts index 5afc9db..671b04d 100644 --- a/src/modules/users/user.service.ts +++ b/src/modules/users/user.service.ts @@ -1,17 +1,16 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@mikro-orm/nestjs'; -import { EntityRepository, FilterQuery } from '@mikro-orm/core'; +import { Injectable } from '@nestjs/common'; +import { FilterQuery } from '@mikro-orm/core'; import { User } from './entities/user.entity'; import { EntityManager } from '@mikro-orm/postgresql'; // import { UpdateUserDto } from './dto/update-user.dto'; import { FindUsersDto } from './dto/find-user.dto'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; +import { UserRepository } from './repositories/user.repository'; @Injectable() export class UserService { constructor( - @InjectRepository(User) - private readonly userRepository: EntityRepository, + private readonly userRepository: UserRepository, private readonly em: EntityManager, ) {}