update server

This commit is contained in:
Admin 2025-10-06 15:06:26 +07:00
parent 805a6361d7
commit 35d407cb4e
51 changed files with 13321 additions and 413 deletions

View File

@ -1 +0,0 @@
BASE_URL = 'https://int.ipsupply.com.au/api/'

66
server/.gitignore vendored
View File

@ -1,30 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
pnpm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules # OS
dist
dist-content
dist-ssr
auto-post-facebook-extensions
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store .DS_Store
*.suo
*.ntvs* # Tests
*.njsproj /coverage
*.sln /.nyc_output
*.sw?
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env .env
product-cache* .env.development.local
meta.json .env.test.local
data.json .env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

4
server/.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
server/README.md Normal file
View File

@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

View File

@ -1,44 +0,0 @@
[
{
"title": "Cisco AIR-AP1542D-Z-K9 802.11ac W2 Value Outdoor AP, Direct. Ant, Z Reg Dom.",
"price": 2563,
"el": {},
"date": "2025-08-27T06:04:03.253Z"
},
{
"title": "Cisco A9K-8T-B 8 Port 10GE Line Card Requires XFPs ASR 9010 Fully Tested",
"price": 850,
"el": {},
"date": "2025-08-27T06:04:03.253Z"
},
{
"title": "Brand New Cisco C9130AXI-Z 802.11ac 9130AX Wireless Access Poiny Z Domain Reg",
"price": 975,
"el": {},
"date": "2025-08-27T06:04:03.253Z"
},
{
"title": "Dell PowerEdge R715 32 Core Server AMD Opteron 128Gb Ram 2x 300Gb HDD TaxInv - R715, SERVER",
"price": 672,
"el": {},
"date": "2025-08-27T06:04:03.253Z"
},
{
"title": "CISCO2911-V/K9 Router UC License + PVDM3-32 Latest IOS SPA.155-3.M4a 1YrWty",
"price": 125,
"el": {},
"date": "2025-08-27T06:04:03.253Z"
},
{
"title": "HP DL380 G9 2U Server 2x CPU E5-2650V3 32Gb RAM 600Gb HDD Dual PSU Rail Kit - DL380 G9, server",
"price": 2245,
"el": {},
"date": "2025-08-27T06:04:03.253Z"
},
{
"title": "Cisco AIR-PWRINJ6 Power Injector 8023at for Aironet Access Points",
"price": 95,
"el": {},
"date": "2025-08-27T06:04:03.253Z"
}
]

31
server/eslint.config.mjs Normal file
View File

@ -0,0 +1,31 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
};

8
server/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11919
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,85 @@
{ {
"name": "server", "name": "server-v2",
"version": "1.0.0", "version": "0.0.1",
"description": "", "description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "node server.js"
},
"keywords": [],
"author": "", "author": "",
"license": "ISC", "private": true,
"type": "module", "license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": { "dependencies": {
"axios": "^1.11.0", "@nestjs/common": "^11.0.1",
"cors": "^2.8.5", "@nestjs/config": "^4.0.2",
"dotenv": "^17.2.1", "@nestjs/core": "^11.0.1",
"express": "^5.1.0", "@nestjs/platform-express": "^11.0.1",
"lodash": "^4.17.21" "@nestjs/schedule": "^6.0.1",
"@nestjs/typeorm": "^11.0.0",
"axios": "^1.12.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"lodash": "^4.17.21",
"mysql2": "^3.15.1",
"nestjs-paginate": "^12.8.3",
"node-cache": "^5.1.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.27"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/cron": "^2.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
} }
} }

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -44,10 +44,19 @@
} }
body { body {
margin: 0; margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, font-family:
Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; ui-sans-serif,
system-ui,
-apple-system,
Segoe UI,
Roboto,
Helvetica,
Arial,
'Apple Color Emoji',
'Segoe UI Emoji';
color: var(--text); color: var(--text);
background: radial-gradient( background:
radial-gradient(
1200px 800px at 80% -10%, 1200px 800px at 80% -10%,
rgba(106, 166, 255, 0.18), rgba(106, 166, 255, 0.18),
transparent 60% transparent 60%
@ -76,7 +85,7 @@
overflow: hidden; overflow: hidden;
} }
.header::after { .header::after {
content: ""; content: '';
position: absolute; position: absolute;
inset: -1px; inset: -1px;
border-radius: inherit; border-radius: inherit;
@ -158,8 +167,9 @@
} }
.kbd { .kbd {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, font-family:
"Liberation Mono", "Courier New", monospace; ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border); border: 1px solid var(--border);
border-bottom-width: 2px; border-bottom-width: 2px;
@ -207,7 +217,7 @@
<time datetime="2025-08-25">August 25, 2025</time> <time datetime="2025-08-25">August 25, 2025</time>
</div> </div>
<div></div> <div></div>
<div><span class="kbd">Version 1.3</span></div> <div><span class="kbd">Version 2.0</span></div>
</div> </div>
</header> </header>

@ -1 +0,0 @@
Subproject commit e7fd818c75881047115599e0227d093b4a460f4e

View File

@ -1,211 +0,0 @@
import express from "express";
import fs from "fs";
import bodyParser from "body-parser";
import cors from "cors";
import axios from "axios";
import path from "path";
import _ from "lodash";
import dotenv from "dotenv";
dotenv.config();
const metaPath = path.join(process.cwd(), "meta.json");
const dataFile = "data.json";
const cachePrefix = "product-cache-";
const productsFile = path.join(process.cwd(), "products.json");
const app = express();
// Cho phép tất cả origin gọi API
app.use(cors());
app.use(express.json({ limit: "10mb" }));
// parse JSON body
app.use(bodyParser.json());
// POST thêm product (tự động update nếu id đã tồn tại)
app.post("/products", (req, res) => {
const { id, ...rest } = req.body;
if (!id) return res.status(400).json({ error: "Product must have an id" });
// Đọc dữ liệu cũ (nếu có)
let data = {};
if (fs.existsSync(productsFile)) {
data = JSON.parse(fs.readFileSync(productsFile, "utf8") || "{}");
}
// Thêm / ghi đè theo id
data[id] = { id, ...rest };
// Ghi lại file
fs.writeFileSync(productsFile, JSON.stringify(data, null, 2));
res.json({ success: true, product: data[id] });
});
// GET product theo id
app.get("/products/:id", (req, res) => {
if (!fs.existsSync(productsFile)) return res.json({});
const data = JSON.parse(fs.readFileSync(productsFile, "utf8") || "{}");
res.json(data[req.params.id] || null);
});
// API lưu dữ liệu
app.post("/sync", (req, res) => {
const data = req.body;
if (!Array.isArray(data)) {
return res.status(400).json({ error: "Dữ liệu phải là array" });
}
// Chuẩn hoá dữ liệu (có thêm date)
const mapped = data.map((item) => ({
...item,
date: new Date().toISOString(),
}));
// Đọc dữ liệu cũ
let oldData = [];
try {
oldData = JSON.parse(fs.readFileSync(dataFile, "utf-8"));
} catch (e) {
oldData = [];
}
// So sánh (bỏ qua field date vì nó luôn khác)
const stripDate = (arr) => arr.map(({ date, ...rest }) => rest);
const oldStripped = stripDate(oldData);
const newStripped = stripDate(mapped);
if (!_.isEqual(oldStripped, newStripped)) {
// Nếu khác → xoá cache
const files = fs.readdirSync(".");
files.forEach((file) => {
if (file.startsWith(cachePrefix)) {
fs.unlinkSync(path.join(".", file));
}
});
console.log("Dữ liệu thay đổi → xoá cache");
} else {
console.log("Dữ liệu không đổi → giữ cache");
}
// Ghi dữ liệu mới
fs.writeFileSync(dataFile, JSON.stringify(mapped, null, 2));
res.json({ message: "Đã lưu dữ liệu thành công", saved: mapped.length });
});
// API đọc lại dữ liệu
app.get("/data", (req, res) => {
if (!fs.existsSync("data.json")) {
return res.json([]);
}
const content = fs.readFileSync("data.json", "utf-8");
res.json(JSON.parse(content));
});
app.post("/", async (req, res) => {
try {
// ===== Helpers =====
const readJSON = (path, fallback = null) => {
try {
return JSON.parse(fs.readFileSync(path, "utf-8"));
} catch {
return fallback;
}
};
const writeJSON = (path, data) => {
try {
fs.writeFileSync(path, JSON.stringify(data, null, 2));
} catch (e) {
console.warn("Không ghi được file:", e.message);
}
};
// ===== Meta =====
let meta = readJSON(metaPath, { total: 4000 });
const { info, ...filter } = req.body?.filter || {};
const originalFilter = { ...filter };
const status = filter?.where?.status_listing;
if (["listed", "unlisted"].includes(status)) {
filter.skip = 0;
}
// ===== Cache check =====
const cacheFile = `${cachePrefix}${JSON.stringify(originalFilter)}.json`;
if (fs.existsSync(cacheFile)) {
return res.json(readJSON(cacheFile, {}));
}
// ===== Load publisted data nếu cần =====
let publistedData = [];
if (status && meta.total) {
filter.limit = meta.total;
publistedData = readJSON("data.json", []);
}
// ===== Call API gốc =====
const { data } = await axios({
headers: { Authorization: req.headers.authorization },
url: "transferGetData",
baseURL: process.env.BASE_URL,
method: "POST",
data: { ...req.body, filter },
});
// Update meta
if (typeof data.total === "number") {
writeJSON(metaPath, { total: data.total });
}
// ===== Xử lý listed/unlisted =====
if (status === "listed" || status === "unlisted") {
const skip = originalFilter.skip || 0;
const limit = originalFilter.limit || data.data.length;
let filteredData = data.data.filter((item) => {
const key = item?.title.includes(item.code)
? `${item.title}`.toLowerCase()
: `${item.title} - ${item.code}`.toLowerCase();
if (status === "listed") {
return publistedData.some(
(i) =>
`${i.title}`.toLowerCase().includes(key) &&
i?.url_info == info?.url
);
} else {
return !publistedData.some(
(i) =>
`${i.title}`.toLowerCase().includes(key) &&
i?.url_info == info?.url
);
}
});
data.total = filteredData.length;
data.data = filteredData.slice(skip, skip + limit);
data.filter = { ...originalFilter, skip, limit };
writeJSON(cacheFile, data);
}
res.json(data);
} catch (err) {
console.error("API error:", err.message);
res.status(500).json({ error: err.message });
}
});
// Route trả về privacy policy
app.get("/policy", (req, res) => {
res.sendFile(path.join(process.cwd(), "views", "policy.html"));
});
app.listen(3000, () => {
console.log("Server chạy tại http://localhost:3000");
});

View File

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

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()
getHello(): string {
return this.appService.getHello();
}
}

42
server/src/app.module.ts Normal file
View File

@ -0,0 +1,42 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CacheModule } from './modules/cache/cache.module';
import { ProductsModule } from './modules/products/products.module';
import { SyncModule } from './modules/sync/sync.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { TaskModule } from './modules/task/task.module';
import { PolicyModule } from './modules/policy/policy.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
CacheModule,
ProductsModule,
TaskModule,
SyncModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'mysql',
host: config.get<string>('DB_HOST'),
port: config.get<number>('DB_PORT'),
username: config.get<string>('DB_USER'),
password: config.get<string>('DB_PASS'),
database: config.get<string>('DB_NAME'),
autoLoadEntities: true,
synchronize: true, // chỉ bật dev
}),
}),
ScheduleModule.forRoot(),
PolicyModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -0,0 +1,26 @@
import { EConfig } from 'src/system/enum/config-enum';
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('configs')
export class Config {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'enum', enum: EConfig, unique: true })
key_name: EConfig;
@Column({ type: 'json' })
value: string;
@CreateDateColumn({ type: 'timestamp', name: 'created_at' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamp', name: 'updated_at' })
updated_at: Date;
}

View File

@ -0,0 +1,40 @@
import {
BeforeInsert,
BeforeUpdate,
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { Product } from './product.entity';
import * as crypto from 'crypto';
@Entity('product_listed')
@Unique(['product', 'info_hash'])
export class ProductListed {
@PrimaryGeneratedColumn('increment')
id: number;
@ManyToOne(() => Product, (product) => product.listedProducts, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'product_id' })
product: Product;
@Column({ type: 'json', nullable: true })
info: Record<string, any>;
// Cho phép null tạm, vì sẽ được fill tự động
@Column({ type: 'varchar', length: 255, nullable: true, default: null })
info_hash: string | null;
@CreateDateColumn({ type: 'timestamp', name: 'created_at' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamp', name: 'updated_at' })
updated_at: Date;
}

View File

@ -0,0 +1,71 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
PrimaryColumn,
OneToOne,
OneToMany,
} from 'typeorm';
import { ProductListed } from './product-listed.entity';
@Entity('products')
export class Product {
@PrimaryColumn('varchar')
ebayListingId: string;
@Column('varchar', { nullable: true, default: null })
id: string;
@Column()
code: string;
@Column({ nullable: true, default: null })
name: string;
@Column('simple-array', { nullable: true, default: null })
listImage: string[];
@Column('simple-array', { nullable: true, default: null })
package_contain: string[];
@Column({ type: 'longtext', nullable: true, default: null })
description: string;
@Column({ type: 'longtext', nullable: true, default: null })
shortDescription: string;
@Column({ nullable: true, default: null })
title: string;
@Column({ nullable: true, default: null })
category: string; // để đơn giản, lưu string thôi
@Column({ type: 'varchar', nullable: true, default: null })
price: string;
@Column({ nullable: true, default: null })
conditionEbay: string;
@Column({ nullable: true, default: null })
condition: string;
@Column({ nullable: true, default: null })
manufactor: string;
@Column({ nullable: true, default: null })
location: string;
@Column({ nullable: true, default: null })
listingId: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => ProductListed, (listed) => listed.product)
listedProducts: ProductListed[];
}

View File

@ -0,0 +1,53 @@
import { Product } from 'src/entities/product.entity';
const productKeys = [
'ebayListingId', // thêm dòng này
'id',
'code',
'listImage',
'package_contain',
'description',
'shortDescription',
'title',
'category',
'price',
'conditionEbay',
'condition',
'manufactor',
'location',
'listingId',
'name',
'createdAt',
'updatedAt',
] as const;
type ProductInput = {
[K in (typeof productKeys)[number]]?: Product[K] | null;
};
export function mapToProduct(
raw: Record<string, any>,
): ProductInput & { ebayListingId: string } {
const mapped: any = {};
mapped.ebayListingId = raw.ebayListingId; // bắt buộc map khóa chính
for (const key of productKeys) {
const value = raw[key];
if (value !== undefined && value !== null) {
if (key === 'createdAt' || key === 'updatedAt') {
mapped[key] = new Date(value);
} else if (key === 'listImage' || key === 'package_contain') {
mapped[key] = Array.isArray(value) ? value.map(String) : [];
} else {
mapped[key] = value;
}
} else {
mapped[key] =
key === 'listImage' || key === 'package_contain' ? [] : null;
}
}
return mapped;
}

14
server/src/main.ts Normal file
View File

@ -0,0 +1,14 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.enableCors({ origin: '*' });
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();

View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { CacheService } from './cache.service';
@Module({
providers: [CacheService],
})
export class CacheModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CacheService } from './cache.service';
describe('CacheService', () => {
let service: CacheService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CacheService],
}).compile();
service = module.get<CacheService>(CacheService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,24 @@
// src/cache/cache.service.ts
import { Injectable } from '@nestjs/common';
import * as NodeCache from 'node-cache';
@Injectable()
export class CacheService {
private cache = new NodeCache({ stdTTL: 60 * 5 }); // 5 phút
get<T>(key: string): T | undefined {
return this.cache.get(key);
}
set<T>(key: string, value: T, ttl: number | string = 10000) {
this.cache.set(key, value, ttl);
}
del(key: string) {
this.cache.del(key);
}
flush() {
this.cache.flushAll();
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';
import { Config } from 'src/entities/config.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forFeature([Config])],
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from './config.service';
describe('ConfigService', () => {
let service: ConfigService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ConfigService],
}).compile();
service = module.get<ConfigService>(ConfigService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Config } from 'src/entities/config.entity';
import { Repository } from 'typeorm';
@Injectable()
export class ConfigService {
constructor(
@InjectRepository(Config)
public readonly repo: Repository<Config>,
) {}
}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PolicyController } from './policy.controller';
describe('PolicyController', () => {
let controller: PolicyController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PolicyController],
}).compile();
controller = module.get<PolicyController>(PolicyController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,21 @@
import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';
import { join } from 'path';
import { existsSync } from 'fs';
@Controller('policy')
export class PolicyController {
@Get()
getPolicyPage(@Res() res: Response) {
// 🔥 Đi ngược từ dist/ ra thư mục gốc, tìm đến public/
const filePath = join(process.cwd(), 'public', 'views', 'policy.html');
if (!existsSync(filePath)) {
return res
.status(404)
.send('Policy page not found. (Missing public/views/policy.html)');
}
return res.sendFile(filePath);
}
}

View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { PolicyController } from './policy.controller';
@Module({
controllers: [PolicyController]
})
export class PolicyModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ProductsController } from './products.controller';
describe('ProductsController', () => {
let controller: ProductsController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ProductsController],
}).compile();
controller = module.get<ProductsController>(ProductsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,29 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Paginate, PaginateQuery } from 'nestjs-paginate';
import { Product } from 'src/entities/product.entity';
import { ProductsService } from './products.service';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
async index(@Paginate() query: PaginateQuery) {
return this.productsService.index(query);
}
@Get(':id')
async show(@Param('id') id: Product['id']) {
return this.productsService.show(id);
}
@Post('publish/:id')
async publish(@Param('id') id: Product['id'], @Body() data: any) {
return this.productsService.publish(id, data);
}
@Post('unlist/:id')
async unlist(@Param('id') id: Product['id'], @Body() data: any) {
return this.productsService.unlist(id, data);
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Product } from 'src/entities/product.entity';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
import { ProductListed } from 'src/entities/product-listed.entity';
@Module({
imports: [TypeOrmModule.forFeature([Product, ProductListed])],
providers: [ProductsService],
controllers: [ProductsController],
exports: [ProductsService],
})
export class ProductsModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ProductsService } from './products.service';
describe('ProductsService', () => {
let service: ProductsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ProductsService],
}).compile();
service = module.get<ProductsService>(ProductsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,162 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { paginate, PaginateQuery } from 'nestjs-paginate';
import axios from 'axios';
import * as crypto from 'crypto';
import { ConfigService as CSL } from '@nestjs/config';
import { Product } from 'src/entities/product.entity';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import AppResponse from 'src/system/response/app-response';
import { SystemLang } from 'src/system/lang/system.lang';
import { ProductListed } from 'src/entities/product-listed.entity';
@Injectable()
export class ProductsService {
constructor(
@InjectRepository(Product)
public readonly repo: Repository<Product>,
@InjectRepository(ProductListed)
public readonly repoProductlisted: Repository<ProductListed>,
private readonly csl: CSL,
readonly configService: ConfigService,
) {}
async index(query: PaginateQuery) {
const qb = this.repo.createQueryBuilder('product');
let info_hash: string | null = null;
// Nếu có filter.info → hash nó lại (ở đây bạn truyền sẵn hash)
if (query.filter?.info) {
info_hash = (query.filter.info as string) || null;
}
// Nếu có info_hash → chỉ join những bản ghi có info_hash đó
if (info_hash) {
qb.leftJoinAndMapMany(
'product.listedProducts',
'product_listed',
'listed',
'listed.product_id = product.ebayListingId AND listed.info_hash = :info_hash',
{ info_hash },
);
} else {
qb.leftJoinAndMapMany(
'product.listedProducts',
'product_listed',
'listed',
'listed.product_id = product.ebayListingId',
);
}
// ----- Lọc theo status -----
if (query.filter?.status === 'listed') {
qb.where('listed.id IS NOT NULL');
} else if (query.filter?.status === 'unlist') {
if (info_hash) {
qb.where(
`product.ebayListingId NOT IN (
SELECT pl.product_id FROM product_listed pl WHERE pl.info_hash = :info_hash
)`,
{ info_hash },
);
} else {
qb.where('listed.id IS NULL');
}
}
const data = await paginate(query, qb, {
sortableColumns: ['createdAt'],
searchableColumns: ['id', 'code', 'name', 'title'],
defaultLimit: 10,
defaultSortBy: [['createdAt', 'DESC'] as any],
maxLimit: 100,
});
return AppResponse.toPagination<Product>(data, true, Product);
}
async show(id: Product['ebayListingId']) {
const product = await this.repo.findOne({ where: { ebayListingId: id } });
if (!product) {
throw new NotFoundException(
AppResponse.toResponse(null, {
message: SystemLang.getText('messages', 'not_found'),
}),
);
}
const a = await axios({
method: 'POST',
headers: { Authorization: `Bearer ${this.csl.get('VITE_API_TOKEN')}` },
url: 'transferGetData',
baseURL: this.csl.get('BASE_URL'),
data: {
filter: {
where: {
account: 'prology_net',
condition: product.condition,
productModelCode: product.code,
},
},
pageCurrent: '/ebaytools/listing-ebay',
urlAPI: '/api/ebay-listing/get-image-listing',
},
});
return AppResponse.toResponse({ ...product, ...a.data?.data });
}
async publish(id: Product['ebayListingId'], data: any) {
const info_hash = data?.hash_info || '';
const prev = await this.repoProductlisted.findOne({
where: { product: { ebayListingId: id }, info_hash: info_hash },
});
if (prev) {
return AppResponse.toResponse(prev);
}
const result = await this.repoProductlisted.save({
product: { ebayListingId: id },
info: data?.info || {},
info_hash: info_hash,
});
if (!result)
throw new BadRequestException(
AppResponse.toResponse(null, {
message: SystemLang.getText('messages', 'error'),
}),
);
return AppResponse.toResponse(result);
}
async unlist(id: Product['ebayListingId'], data: any) {
const info_hash = data?.hash_info || '';
const prev = await this.repoProductlisted.findOne({
where: { product: { ebayListingId: id }, info_hash: info_hash },
});
if (!prev) {
throw new NotFoundException(
AppResponse.toResponse(false, {
message: SystemLang.getText('messages', 'not_found'),
}),
);
}
await this.repoProductlisted.delete({ id: prev.id });
return AppResponse.toResponse(true);
}
}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SyncController } from './sync.controller';
describe('SyncController', () => {
let controller: SyncController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SyncController],
}).compile();
controller = module.get<SyncController>(SyncController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,12 @@
import { Controller, Post } from '@nestjs/common';
import { SyncService } from './sync.service';
@Controller('sync')
export class SyncController {
constructor(readonly syncService: SyncService) {}
@Post()
async sync() {
return this.syncService.sync();
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { SyncService } from './sync.service';
import { ConfigModule } from '../config/config.module';
import { ProductsModule } from '../products/products.module';
import { SyncController } from './sync.controller';
@Module({
imports: [ConfigModule, ProductsModule],
providers: [SyncService],
controllers: [SyncController],
exports: [SyncService],
})
export class SyncModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SyncService } from './sync.service';
describe('SyncService', () => {
let service: SyncService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SyncService],
}).compile();
service = module.get<SyncService>(SyncService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { ConfigService as CSL } from '@nestjs/config';
import axios from 'axios';
import { ConfigService } from '../config/config.service';
import { mapToProduct } from 'src/features/product';
import { ProductsService } from '../products/products.service';
import { EConfig } from 'src/system/enum/config-enum';
import AppResponse from 'src/system/response/app-response';
import { SystemLang } from 'src/system/lang/system.lang';
import { Product } from 'src/entities/product.entity';
import * as _ from 'lodash';
@Injectable()
export class SyncService {
constructor(
private readonly csl: CSL,
private readonly configAppService: ConfigService,
private readonly productService: ProductsService,
) {}
async sync() {
const limit = await this.configAppService.repo.findOne({
where: { key_name: EConfig.LIMIT },
});
const { data } = await axios({
headers: { Authorization: `Bearer ${this.csl.get('VITE_API_TOKEN')}` },
url: 'transferGetData',
baseURL: this.csl.get('BASE_URL'),
method: 'POST',
data: {
urlAPI: '/api/ebay-listing/listing-get-list',
filter: {
skip: 0,
limit: limit?.value || 4180,
order: 'updatedAt desc',
where: {
account: 'prology_net',
status: 'Updated',
productModelCode: '',
},
},
},
});
const rawProducts = data?.data || [];
const productsToSave = rawProducts.map((item: any) => {
return this.productService.repo.create(mapToProduct(item) as any);
});
const result = await this.productService.repo.upsert(productsToSave, [
'ebayListingId',
]);
if (data?.total && data?.total != limit?.value) {
if (!limit) {
await this.configAppService.repo.save({
key_name: EConfig.LIMIT,
value: data?.total,
});
} else {
await this.configAppService.repo.update(
{ key_name: EConfig.LIMIT },
{
key_name: EConfig.LIMIT,
value: data?.total,
},
);
}
}
return AppResponse.toResponse(true, {
message: SystemLang.getText('messages', 'success'),
});
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TasksService } from './task.service';
import { SyncModule } from '../sync/sync.module';
@Module({
imports: [SyncModule],
providers: [TasksService],
})
export class TaskModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TasksService } from './task.service';
describe('TaskService', () => {
let service: TasksService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TasksService],
}).compile();
service = module.get<TasksService>(TasksService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,31 @@
// tasks.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SyncService } from '../sync/sync.service';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
private isRunning = false; // flag để lock
constructor(readonly syncService: SyncService) {}
@Cron(CronExpression.EVERY_30_MINUTES)
async handleCron() {
if (this.isRunning) {
this.logger.warn('Cron job đang chạy, bỏ qua lần trigger mới.');
return;
}
this.isRunning = true;
this.logger.debug('Cron job bắt đầu...');
try {
await this.syncService.sync();
this.logger.debug('Cron job hoàn tất.');
} catch (err) {
this.logger.error('Cron job lỗi:', err);
} finally {
this.isRunning = false; // unlock
}
}
}

View File

@ -0,0 +1,3 @@
export enum EConfig {
LIMIT = 'LIMIT',
}

View File

@ -0,0 +1,113 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
export class SystemLang {
private static texts = {
messages: {
vi: {
error: 'Đã xảy ra lỗi!',
not_found: 'Không tìm thấy dữ liệu!',
file_not_found: 'Không tìm thấy tệp tin!',
unauthorized: 'Bạn không có quyền truy cập!',
forbidden: 'Truy cập bị từ chối!',
success: 'Thành công!',
server_error: 'Lỗi máy chủ, vui lòng thử lại sau!',
connection_lost: 'Mất kết nối, kiểm tra lại mạng!',
try_again: 'Vui lòng thử lại!',
logout_success: 'Bạn đã đăng xuất thành công!',
login_error_input: 'Tên đăng nhập hoặc mật khẩu không chính xác',
login_error_valid: 'Input phải là username hoặc email',
login_required: 'Vui lòng đăng nhập để tiếp tục!',
login_success: 'Đăng nhập thành công!',
save_success: 'Lưu thành công!',
delete_success: 'Xóa thành công!',
delete_failure: 'Xóa không thành công!',
can_not_delete: 'Không thể xóa @key',
no_data: 'Không có dữ liệu để hiển thị!',
default_action: `Hành động này không thể thực hiện được!`,
register_failure: 'Đăng ký tài khoản không thành công!',
register_success: 'Đăng ký tài khoản thành công!',
create_success: 'Đã tạo @key thành công!',
create_failed: 'Không thể tạo @key!',
update_success: 'Đã cập nhật @key thành công!',
update_failed: 'Không thể cập nhật @key!',
already_exists: '@key đã tồn tại!',
required: '@key là bắt buộc!',
invalid: '@key không hợp lệ!',
no_permission: 'Bạn không có quyền thực hiện thao tác với @key!',
password_invalid: 'Mật khẩu không hợp lệ !',
change_pass_success:
'Đổi mật khẩu thành công. Vui lòng đăng nhập lại !',
too_many_request: 'Yêu cầu vượt quá mức quy định',
feature_disabled: 'Tính năng này đang tạm ngưng',
id_not_match: 'ID không khớp!',
},
en: {
error: 'An error occurred!',
id_not_match: 'ID is not match!',
not_found: 'Data not found!',
file_not_found: 'File not found!',
unauthorized: 'You are not authorized!',
forbidden: 'Access denied!',
success: 'Success!',
server_error: 'Server error, please try again later!',
connection_lost: 'Connection lost, check your network!',
try_again: 'Please try again!',
logout_success: 'You have successfully logged out!',
login_error_input: 'Username or Password is cornet',
login_error_valid: 'Input must be a valid username or email',
login_required: 'Please log in to continue!',
login_success: 'Login Success!',
save_success: 'Saved successfully!',
delete_success: 'Delete success!',
delete_failure: 'Delete fail!',
can_not_delete: "Can't delete @key",
no_data: 'No data available!',
default_action: `This action cannot be performed`,
register_failure: 'Register account is fail!',
register_success: 'Register account is success!',
create_success: '@key created successfully!',
create_failed: 'Failed to create @key!',
update_success: '@key updated successfully!',
update_failed: 'Failed to update @key!',
already_exists: '@key already exists!',
required: '@key is required!',
invalid: '@key is invalid!',
no_permission:
'You do not have permission to perform this action on @key!',
password_invalid: 'Password is invalid !',
change_pass_success: 'Change password success. Please re-login !',
too_many_request: 'To many request',
feature_disabled: 'This feature is disabled',
},
},
};
// default lang is VIỆT NAM
public static lang = 'en';
/** ✅ Lấy text từ messages hoặc labels */
static getText<T extends keyof typeof SystemLang.texts>(
type: T,
key: keyof (typeof SystemLang.texts)[T]['vi'],
after?: string,
): string {
const text: string = SystemLang.texts[type][SystemLang.lang][key];
return after
? text.includes('@key')
? text.replaceAll('@key', after)
: `${text} ${after}`
: (text ?? `Missing ${type}: ${String(key)}`);
}
/** ✅ Trả về text từ custom object, fallback nếu không có */
static getCustomText(
texts: { vi?: string; en?: string },
fallbackType: keyof typeof SystemLang.texts = 'messages',
): string {
return (
texts[SystemLang.lang] ||
Object.values(texts)[0] ||
SystemLang.getText(fallbackType, 'error')
);
}
}

View File

@ -0,0 +1,111 @@
import { HttpStatus } from '@nestjs/common';
import { plainToClass, plainToInstance } from 'class-transformer';
import { FilterOperator, FilterSuffix, Paginated } from 'nestjs-paginate';
import { Column } from 'nestjs-paginate/lib/helper';
import { SystemLang } from '../lang/system.lang';
export default class AppResponse {
public static toResponse(
data: any,
options?: { message?: string; status_code?: number; bonus?: any },
) {
return {
message:
options?.message ||
(!Boolean(data)
? SystemLang.getText('messages', 'default_action')
: SystemLang.getText('messages', 'success')),
status_code:
options?.status_code || (!Boolean(data) ? HttpStatus.BAD_REQUEST : 200),
data: data,
timestamp: new Date().toISOString(),
...(options?.bonus || {}),
};
}
public static plainToInStance<M>(this: new (...args: any[]) => M, obj: M): M {
return plainToClass(this, obj);
}
public static toPagination<M>(
{ data, meta }: Paginated<M>,
transform = true,
dtoClass?: new () => M,
) {
let transformedData: any[] = data;
if (transform && dtoClass) {
transformedData = plainToInstance(dtoClass, data);
}
const currentPage = meta.currentPage ?? 1;
const itemsPerPage = meta.itemsPerPage ?? 0;
const totalItems = meta.totalItems ?? 0;
const totalPages = meta.totalPages ?? 0;
return this.toResponse(transformedData, {
bonus: {
current_page: currentPage,
from: currentPage * itemsPerPage - (itemsPerPage - 1),
to:
currentPage * itemsPerPage > totalItems
? totalItems
: currentPage * itemsPerPage,
last_page: totalPages,
per_page: itemsPerPage,
total: totalItems,
},
});
}
public static processFilters<T>(
queryFilters: any,
filterableColumns: {
[key in Column<T> | (string & {})]?:
| (FilterOperator | FilterSuffix)[]
| true;
},
): any {
if (!queryFilters) return {};
const processedFilters = { ...queryFilters };
for (const key in queryFilters) {
const value = queryFilters[key];
if (filterableColumns[key]) {
const columnConfig = filterableColumns[key];
if (Array.isArray(columnConfig)) {
for (const operator of columnConfig) {
if (
operator === FilterOperator.ILIKE &&
!value.startsWith('$ilike')
) {
processedFilters[key] = `$ilike:%${value}%`; // Thêm ký tự đại diện %
break;
}
if (operator === FilterOperator.LTE && !value.startsWith('$lte')) {
processedFilters[key] = `$lte:${value}`;
break;
}
if (operator === FilterOperator.GTE && !value.startsWith('$gte')) {
processedFilters[key] = `$gte:${value}`;
break;
}
if (operator === FilterOperator.EQ && !value.startsWith('$eq')) {
processedFilters[key] = `$eq:${value}`;
break;
}
}
}
// Nếu cột chỉ có giá trị `true` (mặc định EQ)
else if (columnConfig === true && !value.startsWith('$eq')) {
processedFilters[key] = `$eq:${value}`;
}
}
}
return processedFilters;
}
}

View File

@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

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

21
server/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}