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