1st commit
This commit is contained in:
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
DB_URL=postgres://parsdbshop:ZtKKAQWA00umtkNXUMcjVNRD6avXFOVDOfqGcTTLwhnGUYq6EnSvaYsyJi06sx6j@62.3.14.124:6986/postgres
|
||||
REDIS_URL=redis://parsuserdb:xTpObuam6vTAAtWhn92rvQdo8rjhO22K4IxyJxdooUAPoyY9zLbYSYBSRm6io7E6@62.3.14.124:6801/0
|
||||
MINIO_ENDPOINT=s3.ir-thr-at1.arvanstorage.ir
|
||||
MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY=8e66af66-67cb-4dcb-ba62-36e88ad7083e
|
||||
MINIO_SECRET_KEY=770b6bd2f4a93313312dd29bdee80fd57b1490ec86039124b44333a8f150d138
|
||||
MINIO_BUCKET=pod
|
||||
JWT_SECRET=HJAKINMAqi1732bJHGHABADRMESTAhad
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=30d
|
||||
SMS_API_KEY=replace-me
|
||||
OTP_TTL_SECONDS=120
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
npm-debug.log
|
||||
coverage
|
||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: parsshop-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: parsshop
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: parsshop-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: parsshop-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
4
nest-cli.json
Normal file
4
nest-cli.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
7081
package-lock.json
generated
Normal file
7081
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "parsshop-back",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"typeorm": "typeorm-ts-node-commonjs -d src/config/typeorm.config.ts",
|
||||
"migration:generate": "npm run typeorm -- migration:generate src/database/migrations/AutoMigration",
|
||||
"migration:run": "npm run typeorm -- migration:run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.13.1",
|
||||
"redis": "^5.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"prettier": "^3.4.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
12
src/app.controller.ts
Normal file
12
src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get('health')
|
||||
getHealth() {
|
||||
return this.appService.getHealth();
|
||||
}
|
||||
}
|
||||
33
src/app.module.ts
Normal file
33
src/app.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import configuration from './config/configuration';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
import { typeOrmConfigFactory } from './config/typeorm.config';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { Category } from './modules/catalog/entities/category.entity';
|
||||
import { Product } from './modules/catalog/entities/product.entity';
|
||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||
import { User } from './modules/users/entities/user.entity';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
validate: validateEnv,
|
||||
envFilePath: ['.env'],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync(typeOrmConfigFactory),
|
||||
TypeOrmModule.forFeature([User, Product, Category]),
|
||||
UsersModule,
|
||||
CatalogModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
12
src/app.service.ts
Normal file
12
src/app.service.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHealth() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'parsshop-back',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
5
src/common/decorators/permissions.decorator.ts
Normal file
5
src/common/decorators/permissions.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const Permissions = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
5
src/common/decorators/roles.decorator.ts
Normal file
5
src/common/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '../../modules/users/enums/user-role.enum';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
32
src/common/guards/permissions.guard.ts
Normal file
32
src/common/guards/permissions.guard.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
|
||||
import { JwtPayload } from '../../modules/auth/interfaces/jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
||||
PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user as JwtPayload | undefined;
|
||||
const permissions = user?.permissions ?? [];
|
||||
|
||||
return requiredPermissions.every((permission) =>
|
||||
permissions.includes(permission),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/common/guards/roles.guard.ts
Normal file
29
src/common/guards/roles.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import { JwtPayload } from '../../modules/auth/interfaces/jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user as JwtPayload | undefined;
|
||||
|
||||
return !!user && requiredRoles.includes(user.role);
|
||||
}
|
||||
}
|
||||
42
src/common/interceptors/response.interceptor.ts
Normal file
42
src/common/interceptors/response.interceptor.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { map, Observable } from 'rxjs';
|
||||
|
||||
export interface StandardApiResponse<T> {
|
||||
success: boolean;
|
||||
statusCode: number;
|
||||
path: string;
|
||||
timestamp: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor<T>
|
||||
implements NestInterceptor<T, StandardApiResponse<T>>
|
||||
{
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<StandardApiResponse<T>> {
|
||||
const http = context.switchToHttp();
|
||||
const response = http.getResponse();
|
||||
const request = http.getRequest();
|
||||
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
success: true,
|
||||
statusCode: response.statusCode,
|
||||
path: request.url,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
src/config/configuration.ts
Normal file
30
src/config/configuration.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export default () => ({
|
||||
app: {
|
||||
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
},
|
||||
database: {
|
||||
url: process.env.DB_URL,
|
||||
},
|
||||
redis: {
|
||||
url: process.env.REDIS_URL,
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET,
|
||||
accessTtl: process.env.JWT_ACCESS_TTL ?? '15m',
|
||||
refreshTtl: process.env.JWT_REFRESH_TTL ?? '30d',
|
||||
},
|
||||
sms: {
|
||||
apiKey: process.env.SMS_API_KEY,
|
||||
},
|
||||
otp: {
|
||||
ttlSeconds: parseInt(process.env.OTP_TTL_SECONDS ?? '120', 10),
|
||||
},
|
||||
minio: {
|
||||
endpoint: process.env.MINIO_ENDPOINT,
|
||||
port: parseInt(process.env.MINIO_PORT ?? '9000', 10),
|
||||
accessKey: process.env.MINIO_ACCESS_KEY,
|
||||
secretKey: process.env.MINIO_SECRET_KEY,
|
||||
bucket: process.env.MINIO_BUCKET,
|
||||
},
|
||||
});
|
||||
56
src/config/env.validation.ts
Normal file
56
src/config/env.validation.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { IsNotEmpty, IsNumberString, IsOptional, IsString, validateSync } from 'class-validator';
|
||||
|
||||
class EnvironmentVariables {
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
PORT?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
NODE_ENV?: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
DB_URL!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
REDIS_URL?: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
JWT_SECRET!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
JWT_ACCESS_TTL?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
JWT_REFRESH_TTL?: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
SMS_API_KEY!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
OTP_TTL_SECONDS?: string;
|
||||
}
|
||||
|
||||
export function validateEnv(config: Record<string, unknown>) {
|
||||
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
|
||||
const errors = validateSync(validatedConfig, {
|
||||
skipMissingProperties: false,
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.toString());
|
||||
}
|
||||
|
||||
return validatedConfig;
|
||||
}
|
||||
21
src/config/typeorm.config.ts
Normal file
21
src/config/typeorm.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModuleAsyncOptions, TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { Category } from '../modules/catalog/entities/category.entity';
|
||||
import { Product } from '../modules/catalog/entities/product.entity';
|
||||
import { User } from '../modules/users/entities/user.entity';
|
||||
|
||||
export const buildTypeOrmOptions = (
|
||||
configService: ConfigService,
|
||||
): TypeOrmModuleOptions => ({
|
||||
type: 'postgres',
|
||||
url: configService.get<string>('database.url'),
|
||||
entities: [User, Product, Category],
|
||||
autoLoadEntities: false,
|
||||
synchronize: true,
|
||||
});
|
||||
|
||||
export const typeOrmConfigFactory: TypeOrmModuleAsyncOptions = {
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => buildTypeOrmOptions(configService),
|
||||
};
|
||||
26
src/main.ts
Normal file
26
src/main.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory, Reflector } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const reflector = app.get(Reflector);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.useGlobalInterceptors(new ResponseInterceptor(reflector));
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
57
src/modules/auth/auth.controller.ts
Normal file
57
src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { Permissions } from '../../common/decorators/permissions.decorator';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { UserRole } from '../users/enums/user-role.enum';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('otp/request')
|
||||
requestOtp(@Body() dto: RequestOtpDto) {
|
||||
return this.authService.requestOtp(dto.phone, dto.fullName);
|
||||
}
|
||||
|
||||
@Post('otp/verify')
|
||||
verifyOtp(@Body() dto: VerifyOtpDto) {
|
||||
return this.authService.verifyOtp(dto.phone, dto.otp);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refreshToken(dto.refreshToken);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('logout')
|
||||
logout(@Req() request: Request & { user: JwtPayload }) {
|
||||
return this.authService.logout(request.user.sub);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Permissions('users.manage')
|
||||
@Get('me/admin-check')
|
||||
adminCheck(@Req() request: Request & { user: JwtPayload }) {
|
||||
return {
|
||||
user: request.user,
|
||||
authorized: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
src/modules/auth/auth.module.ts
Normal file
29
src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
ConfigModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.getOrThrow<string>('jwt.secret'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, RolesGuard, PermissionsGuard],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
169
src/modules/auth/auth.service.ts
Normal file
169
src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { StringValue } from 'ms';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { UserRole } from '../users/enums/user-role.enum';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async requestOtp(phone: string, fullName?: string) {
|
||||
const user = await this.usersService.findOrCreateByPhone(phone, fullName);
|
||||
const otpCode = this.generateOtp();
|
||||
const ttlSeconds = this.configService.get<number>('otp.ttlSeconds', 120);
|
||||
|
||||
user.otpCode = otpCode;
|
||||
user.otpExpiresAt = new Date(Date.now() + ttlSeconds * 1000);
|
||||
await this.usersService.save(user);
|
||||
|
||||
return {
|
||||
message: 'OTP generated successfully',
|
||||
expiresInSeconds: ttlSeconds,
|
||||
phone,
|
||||
otpPreview: otpCode,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyOtp(phone: string, otp: string) {
|
||||
const user = await this.usersService.findByPhone(phone);
|
||||
|
||||
if (!user || !user.otpCode || !user.otpExpiresAt) {
|
||||
throw new UnauthorizedException('OTP not requested');
|
||||
}
|
||||
|
||||
if (user.otpExpiresAt.getTime() < Date.now()) {
|
||||
throw new UnauthorizedException('OTP expired');
|
||||
}
|
||||
|
||||
if (user.otpCode !== otp) {
|
||||
throw new BadRequestException('Invalid OTP');
|
||||
}
|
||||
|
||||
user.isVerified = true;
|
||||
user.otpCode = null;
|
||||
user.otpExpiresAt = null;
|
||||
|
||||
const tokens = await this.issueTokens(user);
|
||||
await this.storeRefreshToken(user, tokens.refreshToken);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string) {
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||||
secret: this.configService.getOrThrow<string>('jwt.secret'),
|
||||
});
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findByPhone(payload.phone);
|
||||
|
||||
if (!user?.refreshTokenHash) {
|
||||
throw new UnauthorizedException('Refresh token not found');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(refreshToken, user.refreshTokenHash);
|
||||
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
const tokens = await this.issueTokens(user);
|
||||
await this.storeRefreshToken(user, tokens.refreshToken);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async logout(userId: string) {
|
||||
const user = await this.findUserById(userId);
|
||||
user.refreshTokenHash = null;
|
||||
await this.usersService.save(user);
|
||||
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
private async issueTokens(user: User) {
|
||||
const accessPayload: JwtPayload = {
|
||||
sub: user.id,
|
||||
phone: user.phone,
|
||||
role: user.role,
|
||||
level: user.level,
|
||||
permissions: this.resolvePermissions(user),
|
||||
type: 'access',
|
||||
};
|
||||
|
||||
const refreshPayload: JwtPayload = {
|
||||
...accessPayload,
|
||||
type: 'refresh',
|
||||
};
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(accessPayload, {
|
||||
secret: this.configService.getOrThrow<string>('jwt.secret'),
|
||||
expiresIn: this.configService.getOrThrow<StringValue>('jwt.accessTtl'),
|
||||
}),
|
||||
this.jwtService.signAsync(refreshPayload, {
|
||||
secret: this.configService.getOrThrow<string>('jwt.secret'),
|
||||
expiresIn: this.configService.getOrThrow<StringValue>('jwt.refreshTtl'),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
phone: user.phone,
|
||||
fullName: user.fullName,
|
||||
role: user.role,
|
||||
level: user.level,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async storeRefreshToken(user: User, refreshToken: string) {
|
||||
user.refreshTokenHash = await bcrypt.hash(refreshToken, 10);
|
||||
await this.usersService.save(user);
|
||||
}
|
||||
|
||||
private generateOtp() {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
private resolvePermissions(user: User) {
|
||||
if (user.role === UserRole.ADMIN) {
|
||||
return ['products.manage', 'categories.manage', 'users.manage'];
|
||||
}
|
||||
|
||||
if (user.role === UserRole.AGENT) {
|
||||
return ['orders.assist', 'customers.assist'];
|
||||
}
|
||||
|
||||
return ['profile.read'];
|
||||
}
|
||||
|
||||
private async findUserById(userId: string) {
|
||||
const user = await this.usersService.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
7
src/modules/auth/dto/refresh-token.dto.ts
Normal file
7
src/modules/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
refreshToken: string;
|
||||
}
|
||||
12
src/modules/auth/dto/request-otp.dto.ts
Normal file
12
src/modules/auth/dto/request-otp.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class RequestOtpDto {
|
||||
@Matches(/^\+?[1-9]\d{7,14}$/)
|
||||
phone: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(150)
|
||||
fullName?: string;
|
||||
}
|
||||
10
src/modules/auth/dto/verify-otp.dto.ts
Normal file
10
src/modules/auth/dto/verify-otp.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@Matches(/^\+?[1-9]\d{7,14}$/)
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@Length(4, 6)
|
||||
otp: string;
|
||||
}
|
||||
5
src/modules/auth/guards/jwt-auth.guard.ts
Normal file
5
src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
11
src/modules/auth/interfaces/jwt-payload.interface.ts
Normal file
11
src/modules/auth/interfaces/jwt-payload.interface.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { UserLevel } from '../../users/enums/user-level.enum';
|
||||
import { UserRole } from '../../users/enums/user-role.enum';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
phone: string;
|
||||
role: UserRole;
|
||||
level: UserLevel;
|
||||
permissions: string[];
|
||||
type: 'access' | 'refresh';
|
||||
}
|
||||
20
src/modules/auth/strategies/jwt.strategy.ts
Normal file
20
src/modules/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.getOrThrow<string>('jwt.secret'),
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
10
src/modules/catalog/catalog.module.ts
Normal file
10
src/modules/catalog/catalog.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Category } from './entities/category.entity';
|
||||
import { Product } from './entities/product.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Category, Product])],
|
||||
exports: [TypeOrmModule],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
36
src/modules/catalog/entities/category.entity.ts
Normal file
36
src/modules/catalog/entities/category.entity.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ name: 'categories' })
|
||||
export class Category {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true, length: 150 })
|
||||
name: string;
|
||||
|
||||
@Column({ unique: true, length: 180 })
|
||||
slug: string;
|
||||
|
||||
@ManyToOne(() => Category, (category) => category.children, {
|
||||
nullable: true,
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
parent?: Category | null;
|
||||
|
||||
@OneToMany(() => Category, (category) => category.parent)
|
||||
children: Category[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
63
src/modules/catalog/entities/product.entity.ts
Normal file
63
src/modules/catalog/entities/product.entity.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ProductType } from '../enums/product-type.enum';
|
||||
import { Category } from './category.entity';
|
||||
|
||||
@Entity({ name: 'products' })
|
||||
export class Product {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true, length: 80 })
|
||||
sku: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'technical_code', length: 120 })
|
||||
technicalCode: string;
|
||||
|
||||
@Column({ length: 120 })
|
||||
brand: string;
|
||||
|
||||
@Column({
|
||||
name: 'base_price_usd',
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string) => Number(value),
|
||||
},
|
||||
})
|
||||
basePriceUSD: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
stock: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ProductType,
|
||||
})
|
||||
type: ProductType;
|
||||
|
||||
@Column({ name: '3d_model_url', nullable: true, length: 500 })
|
||||
threeDModelUrl?: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
attributes: Record<string, unknown>;
|
||||
|
||||
@ManyToOne(() => Category, { nullable: true, onDelete: 'SET NULL' })
|
||||
category?: Category | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
4
src/modules/catalog/enums/product-type.enum.ts
Normal file
4
src/modules/catalog/enums/product-type.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum ProductType {
|
||||
INDUSTRIAL = 'Industrial',
|
||||
AUTOMOTIVE = 'Automotive',
|
||||
}
|
||||
66
src/modules/users/entities/user.entity.ts
Normal file
66
src/modules/users/entities/user.entity.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { UserLevel } from '../enums/user-level.enum';
|
||||
import { UserRole } from '../enums/user-role.enum';
|
||||
|
||||
@Entity({ name: 'users' })
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true, length: 20 })
|
||||
phone: string;
|
||||
|
||||
@Column({ name: 'full_name', length: 150 })
|
||||
fullName: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: UserRole,
|
||||
default: UserRole.USER,
|
||||
})
|
||||
role: UserRole;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: UserLevel,
|
||||
default: UserLevel.BRONZE,
|
||||
})
|
||||
level: UserLevel;
|
||||
|
||||
@Column({ name: 'is_verified', default: false })
|
||||
isVerified: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'wallet_balance',
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string) => Number(value),
|
||||
},
|
||||
})
|
||||
walletBalance: number;
|
||||
|
||||
@Column({ name: 'otp_code', nullable: true, length: 10 })
|
||||
otpCode?: string | null;
|
||||
|
||||
@Column({ name: 'otp_expires_at', nullable: true, type: 'timestamp with time zone' })
|
||||
otpExpiresAt?: Date | null;
|
||||
|
||||
@Column({ name: 'refresh_token_hash', nullable: true, length: 255 })
|
||||
refreshTokenHash?: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
5
src/modules/users/enums/user-level.enum.ts
Normal file
5
src/modules/users/enums/user-level.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum UserLevel {
|
||||
BRONZE = 'Bronze',
|
||||
SILVER = 'Silver',
|
||||
GOLD = 'Gold',
|
||||
}
|
||||
5
src/modules/users/enums/user-role.enum.ts
Normal file
5
src/modules/users/enums/user-role.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum UserRole {
|
||||
ADMIN = 'Admin',
|
||||
USER = 'User',
|
||||
AGENT = 'Agent',
|
||||
}
|
||||
11
src/modules/users/users.module.ts
Normal file
11
src/modules/users/users.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
40
src/modules/users/users.service.ts
Normal file
40
src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UserRole } from './enums/user-role.enum';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly usersRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
findByPhone(phone: string) {
|
||||
return this.usersRepository.findOne({ where: { phone } });
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
return this.usersRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findOrCreateByPhone(phone: string, fullName?: string) {
|
||||
let user = await this.findByPhone(phone);
|
||||
|
||||
if (!user) {
|
||||
user = this.usersRepository.create({
|
||||
phone,
|
||||
fullName: fullName ?? phone,
|
||||
role: UserRole.USER,
|
||||
});
|
||||
user = await this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async save(user: User) {
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
}
|
||||
9
tsconfig.build.json
Normal file
9
tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"test",
|
||||
"dist",
|
||||
"**/*spec.ts"
|
||||
]
|
||||
}
|
||||
32
tsconfig.json
Normal file
32
tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"strictPropertyInitialization": false,
|
||||
"paths": {
|
||||
"src/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user