1st commit

This commit is contained in:
2026-03-23 10:59:36 +03:00
parent 9c37173552
commit 7109325bff
38 changed files with 8103 additions and 0 deletions

14
.env.example Normal file
View 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
View File

@@ -0,0 +1,5 @@
node_modules
dist
.env
npm-debug.log
coverage

43
docker-compose.yml Normal file
View 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
View File

@@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

7081
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View 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
View 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
View 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
View 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(),
};
}
}

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const PERMISSIONS_KEY = 'permissions';
export const Permissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);

View 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);

View 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),
);
}
}

View 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);
}
}

View 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,
})),
);
}
}

View 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,
},
});

View 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;
}

View 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
View 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();

View 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,
};
}
}

View 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 {}

View 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;
}
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class RefreshTokenDto {
@IsString()
@MinLength(10)
refreshToken: string;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View 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';
}

View 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;
}
}

View 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 {}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,4 @@
export enum ProductType {
INDUSTRIAL = 'Industrial',
AUTOMOTIVE = 'Automotive',
}

View 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;
}

View File

@@ -0,0 +1,5 @@
export enum UserLevel {
BRONZE = 'Bronze',
SILVER = 'Silver',
GOLD = 'Gold',
}

View File

@@ -0,0 +1,5 @@
export enum UserRole {
ADMIN = 'Admin',
USER = 'User',
AGENT = 'Agent',
}

View 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 {}

View 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
View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"exclude": [
"node_modules",
"test",
"dist",
"**/*spec.ts"
]
}

32
tsconfig.json Normal file
View 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"
]
}