feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma binaryTargets for openssl 3.0 - nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal cron, http->https redirect - prisma: add origin_goods.delisted migration, sync missing schema (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz - api: CORS wildcard reflection, helmet CORP cross-origin, price backfill in persistProductDetail, categoryIcon ancestor fallback, mediaByColor per-color gallery in public goods detail - admin: /admin base path (vite + router) - import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
+20
-20
@@ -1,20 +1,20 @@
|
||||
# Prisma connection string (PostgreSQL)
|
||||
DATABASE_URL=postgresql://postgres:CHANGE_ME@localhost:5432/inkreach-official-website
|
||||
|
||||
# JWT signing secret: generate with `node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"`
|
||||
# Must be at least 32 characters.
|
||||
JWT_SECRET=CHANGE_ME_TO_A_STRONG_RANDOM_SECRET
|
||||
|
||||
# Access token lifetime (e.g. 30m, 12h); short-lived, rotated via /auth/refresh
|
||||
TOKEN_EXPIRES_IN=30m
|
||||
|
||||
# Refresh token lifetime (HttpOnly cookie)
|
||||
REFRESH_TOKEN_EXPIRES_IN=7d
|
||||
|
||||
# Comma-separated list of allowed CORS origins (leave empty to disable CORS)
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# Global rate limit per minute (per IP)
|
||||
THROTTLE_LIMIT=120
|
||||
|
||||
PORT=3001
|
||||
# Prisma connection string (PostgreSQL)
|
||||
DATABASE_URL=postgresql://postgres:CHANGE_ME@localhost:5432/inkreach-official-website
|
||||
|
||||
# JWT signing secret: generate with `node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"`
|
||||
# Must be at least 32 characters.
|
||||
JWT_SECRET=CHANGE_ME_TO_A_STRONG_RANDOM_SECRET
|
||||
|
||||
# Access token lifetime (e.g. 30m, 12h); short-lived, rotated via /auth/refresh
|
||||
TOKEN_EXPIRES_IN=30m
|
||||
|
||||
# Refresh token lifetime (HttpOnly cookie)
|
||||
REFRESH_TOKEN_EXPIRES_IN=7d
|
||||
|
||||
# Comma-separated list of allowed CORS origins (leave empty to disable CORS)
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# Global rate limit per minute (per IP)
|
||||
THROTTLE_LIMIT=120
|
||||
|
||||
PORT=3001
|
||||
|
||||
+24
-24
@@ -1,25 +1,25 @@
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
};
|
||||
+47
-47
@@ -1,48 +1,48 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
|
||||
# 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
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Prisma
|
||||
# prisma/migrations (keep migrations in VCS)
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Uploads
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
|
||||
# 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
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Prisma
|
||||
# prisma/migrations (keep migrations in VCS)
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Uploads
|
||||
/uploads
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"printWidth": 100
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
+14
-14
@@ -1,15 +1,15 @@
|
||||
module.exports = {
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
coveragePathIgnorePatterns: ['/node_modules/', '/test/'],
|
||||
module.exports = {
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
coveragePathIgnorePatterns: ['/node_modules/', '/test/'],
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": false
|
||||
}
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": false
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,54 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const countryIcons: Record<string, string> = {
|
||||
美国: '/assets/product-center/countries/us.png',
|
||||
日本: '/assets/product-center/countries/jp.png',
|
||||
墨西哥: '/assets/product-center/countries/mx.png',
|
||||
巴西: '/assets/product-center/countries/br.png',
|
||||
中东: '/assets/product-center/countries/middle-east.png',
|
||||
波兰: '/assets/product-center/countries/pl.png',
|
||||
西班牙: '/assets/product-center/countries/es.png',
|
||||
德国: '/assets/product-center/countries/de.png',
|
||||
意大利: '/assets/product-center/countries/it.png',
|
||||
英国: '/assets/product-center/countries/gb.png',
|
||||
加拿大: '/assets/product-center/countries/ca.png',
|
||||
澳大利亚: '/assets/product-center/countries/au.png',
|
||||
韩国: '/assets/product-center/countries/kr.png',
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, string> = {
|
||||
男士服装: '/assets/product-center/categories/men.svg',
|
||||
女士服装: '/assets/product-center/categories/women.svg',
|
||||
儿童服装: '/assets/product-center/categories/children.svg',
|
||||
家居配饰: '/assets/product-center/categories/home.svg',
|
||||
};
|
||||
|
||||
async function updateExistingIcons(
|
||||
entries: Record<string, string>,
|
||||
update: (name: string, icon: string) => Promise<{ count: number }>,
|
||||
): Promise<number> {
|
||||
let updated = 0;
|
||||
for (const [name, icon] of Object.entries(entries)) {
|
||||
const result = await update(name, icon);
|
||||
updated += result.count;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const countries = await updateExistingIcons(countryIcons, (countryName, countryIcon) =>
|
||||
prisma.country.updateMany({ where: { countryName }, data: { countryIcon } }),
|
||||
);
|
||||
const categories = await updateExistingIcons(categoryIcons, (categoryName, categoryIcon) =>
|
||||
prisma.category.updateMany({
|
||||
where: { categoryName, parentCategoryId: null },
|
||||
data: { categoryIcon },
|
||||
}),
|
||||
);
|
||||
console.log(`Configured ${countries} countries and ${categories} root categories.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.finally(async () => prisma.$disconnect());
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const countryIcons: Record<string, string> = {
|
||||
美国: '/assets/product-center/countries/us.png',
|
||||
日本: '/assets/product-center/countries/jp.png',
|
||||
墨西哥: '/assets/product-center/countries/mx.png',
|
||||
巴西: '/assets/product-center/countries/br.png',
|
||||
中东: '/assets/product-center/countries/middle-east.png',
|
||||
波兰: '/assets/product-center/countries/pl.png',
|
||||
西班牙: '/assets/product-center/countries/es.png',
|
||||
德国: '/assets/product-center/countries/de.png',
|
||||
意大利: '/assets/product-center/countries/it.png',
|
||||
英国: '/assets/product-center/countries/gb.png',
|
||||
加拿大: '/assets/product-center/countries/ca.png',
|
||||
澳大利亚: '/assets/product-center/countries/au.png',
|
||||
韩国: '/assets/product-center/countries/kr.png',
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, string> = {
|
||||
男士服装: '/assets/product-center/categories/men.svg',
|
||||
女士服装: '/assets/product-center/categories/women.svg',
|
||||
儿童服装: '/assets/product-center/categories/children.svg',
|
||||
家居配饰: '/assets/product-center/categories/home.svg',
|
||||
};
|
||||
|
||||
async function updateExistingIcons(
|
||||
entries: Record<string, string>,
|
||||
update: (name: string, icon: string) => Promise<{ count: number }>,
|
||||
): Promise<number> {
|
||||
let updated = 0;
|
||||
for (const [name, icon] of Object.entries(entries)) {
|
||||
const result = await update(name, icon);
|
||||
updated += result.count;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const countries = await updateExistingIcons(countryIcons, (countryName, countryIcon) =>
|
||||
prisma.country.updateMany({ where: { countryName }, data: { countryIcon } }),
|
||||
);
|
||||
const categories = await updateExistingIcons(categoryIcons, (categoryName, categoryIcon) =>
|
||||
prisma.category.updateMany({
|
||||
where: { categoryName, parentCategoryId: null },
|
||||
data: { categoryIcon },
|
||||
}),
|
||||
);
|
||||
console.log(`Configured ${countries} countries and ${categories} root categories.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.finally(async () => prisma.$disconnect());
|
||||
|
||||
@@ -1,187 +1,187 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SyncType" AS ENUM ('CATEGORIES', 'PRODUCTS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SyncStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "origin_goods" (
|
||||
"origin_good_id" BIGSERIAL NOT NULL,
|
||||
"sds_good_id" TEXT NOT NULL,
|
||||
"sds_category_id" TEXT,
|
||||
"good_name" TEXT,
|
||||
"good_image" TEXT,
|
||||
"good_price" DECIMAL(12,2),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "origin_goods_pkey" PRIMARY KEY ("origin_good_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "countries" (
|
||||
"country_id" BIGSERIAL NOT NULL,
|
||||
"country_name" TEXT NOT NULL,
|
||||
"country_icon" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "countries_pkey" PRIMARY KEY ("country_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "categories" (
|
||||
"category_id" BIGSERIAL NOT NULL,
|
||||
"parent_category_id" BIGINT,
|
||||
"category_name" TEXT NOT NULL,
|
||||
"category_icon" TEXT,
|
||||
"sds_category_id" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "categories_pkey" PRIMARY KEY ("category_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tags" (
|
||||
"tag_id" BIGSERIAL NOT NULL,
|
||||
"tag_name" TEXT NOT NULL,
|
||||
"tag_color" TEXT,
|
||||
"timing" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tags_pkey" PRIMARY KEY ("tag_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "positions" (
|
||||
"position_id" BIGSERIAL NOT NULL,
|
||||
"index_val" INTEGER NOT NULL,
|
||||
"country_id" BIGINT,
|
||||
"category_id" BIGINT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "positions_pkey" PRIMARY KEY ("position_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "goods" (
|
||||
"good_id" BIGSERIAL NOT NULL,
|
||||
"origin_good_id" BIGINT NOT NULL,
|
||||
"country_id" BIGINT NOT NULL,
|
||||
"category_id" BIGINT NOT NULL,
|
||||
"tag_id" BIGINT,
|
||||
"position_id" BIGINT,
|
||||
"good_name" TEXT NOT NULL,
|
||||
"good_priority" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "goods_pkey" PRIMARY KEY ("good_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sync_logs" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"type" "SyncType" NOT NULL,
|
||||
"status" "SyncStatus" NOT NULL,
|
||||
"message" TEXT,
|
||||
"started_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finished_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "sync_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "origin_goods_sds_good_id_key" ON "origin_goods"("sds_good_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "origin_goods_sds_category_id_idx" ON "origin_goods"("sds_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "countries_country_name_key" ON "countries"("country_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "categories_sds_category_id_key" ON "categories"("sds_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "categories_parent_category_id_idx" ON "categories"("parent_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tags_tag_name_key" ON "tags"("tag_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_country_id_idx" ON "positions"("country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_category_id_idx" ON "positions"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_country_id_category_id_idx" ON "positions"("country_id", "category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_origin_good_id_idx" ON "goods"("origin_good_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_country_id_idx" ON "goods"("country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_category_id_idx" ON "goods"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_tag_id_idx" ON "goods"("tag_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_position_id_idx" ON "goods"("position_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_good_priority_idx" ON "goods"("good_priority" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_country_id_good_priority_idx" ON "goods"("country_id", "good_priority" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_category_id_country_id_idx" ON "goods"("category_id", "country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sync_logs_type_started_at_idx" ON "sync_logs"("type", "started_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_category_id_fkey" FOREIGN KEY ("parent_category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "positions" ADD CONSTRAINT "positions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "positions" ADD CONSTRAINT "positions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_position_id_fkey" FOREIGN KEY ("position_id") REFERENCES "positions"("position_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SyncType" AS ENUM ('CATEGORIES', 'PRODUCTS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SyncStatus" AS ENUM ('RUNNING', 'SUCCESS', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "origin_goods" (
|
||||
"origin_good_id" BIGSERIAL NOT NULL,
|
||||
"sds_good_id" TEXT NOT NULL,
|
||||
"sds_category_id" TEXT,
|
||||
"good_name" TEXT,
|
||||
"good_image" TEXT,
|
||||
"good_price" DECIMAL(12,2),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "origin_goods_pkey" PRIMARY KEY ("origin_good_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "countries" (
|
||||
"country_id" BIGSERIAL NOT NULL,
|
||||
"country_name" TEXT NOT NULL,
|
||||
"country_icon" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "countries_pkey" PRIMARY KEY ("country_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "categories" (
|
||||
"category_id" BIGSERIAL NOT NULL,
|
||||
"parent_category_id" BIGINT,
|
||||
"category_name" TEXT NOT NULL,
|
||||
"category_icon" TEXT,
|
||||
"sds_category_id" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "categories_pkey" PRIMARY KEY ("category_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tags" (
|
||||
"tag_id" BIGSERIAL NOT NULL,
|
||||
"tag_name" TEXT NOT NULL,
|
||||
"tag_color" TEXT,
|
||||
"timing" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tags_pkey" PRIMARY KEY ("tag_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "positions" (
|
||||
"position_id" BIGSERIAL NOT NULL,
|
||||
"index_val" INTEGER NOT NULL,
|
||||
"country_id" BIGINT,
|
||||
"category_id" BIGINT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "positions_pkey" PRIMARY KEY ("position_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "goods" (
|
||||
"good_id" BIGSERIAL NOT NULL,
|
||||
"origin_good_id" BIGINT NOT NULL,
|
||||
"country_id" BIGINT NOT NULL,
|
||||
"category_id" BIGINT NOT NULL,
|
||||
"tag_id" BIGINT,
|
||||
"position_id" BIGINT,
|
||||
"good_name" TEXT NOT NULL,
|
||||
"good_priority" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "goods_pkey" PRIMARY KEY ("good_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sync_logs" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"type" "SyncType" NOT NULL,
|
||||
"status" "SyncStatus" NOT NULL,
|
||||
"message" TEXT,
|
||||
"started_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finished_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "sync_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "origin_goods_sds_good_id_key" ON "origin_goods"("sds_good_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "origin_goods_sds_category_id_idx" ON "origin_goods"("sds_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "countries_country_name_key" ON "countries"("country_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "categories_sds_category_id_key" ON "categories"("sds_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "categories_parent_category_id_idx" ON "categories"("parent_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tags_tag_name_key" ON "tags"("tag_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_country_id_idx" ON "positions"("country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_category_id_idx" ON "positions"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "positions_country_id_category_id_idx" ON "positions"("country_id", "category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_origin_good_id_idx" ON "goods"("origin_good_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_country_id_idx" ON "goods"("country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_category_id_idx" ON "goods"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_tag_id_idx" ON "goods"("tag_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_position_id_idx" ON "goods"("position_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_good_priority_idx" ON "goods"("good_priority" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_country_id_good_priority_idx" ON "goods"("country_id", "good_priority" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goods_category_id_country_id_idx" ON "goods"("category_id", "country_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sync_logs_type_started_at_idx" ON "sync_logs"("type", "started_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_category_id_fkey" FOREIGN KEY ("parent_category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "positions" ADD CONSTRAINT "positions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "positions" ADD CONSTRAINT "positions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goods" ADD CONSTRAINT "goods_position_id_fkey" FOREIGN KEY ("position_id") REFERENCES "positions"("position_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "tags" ADD COLUMN "tag_group_id" BIGINT,
|
||||
ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tag_groups" (
|
||||
"tag_group_id" BIGSERIAL NOT NULL,
|
||||
"group_name" TEXT NOT NULL,
|
||||
"group_icon" TEXT,
|
||||
"group_color" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tag_groups_pkey" PRIMARY KEY ("tag_group_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tag_groups_group_name_key" ON "tag_groups"("group_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tags_tag_group_id_idx" ON "tags"("tag_group_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tags_tag_group_id_sort_order_idx" ON "tags"("tag_group_id", "sort_order");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tags" ADD CONSTRAINT "tags_tag_group_id_fkey" FOREIGN KEY ("tag_group_id") REFERENCES "tag_groups"("tag_group_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- Seed: insert 3 default tag groups
|
||||
INSERT INTO "tag_groups" ("group_name", "sort_order", "created_at", "updated_at") VALUES
|
||||
('物流渠道', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('印刷位置', 2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('印刷工艺', 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
|
||||
-- Seed: assign existing tags to groups
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '物流渠道')
|
||||
WHERE "tag_name" IN ('包邮', '不包邮');
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷位置')
|
||||
WHERE "tag_name" IN ('单面印', '双面印');
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷工艺')
|
||||
WHERE "tag_name" IN ('烫画', '直喷', '不打印');
|
||||
|
||||
-- Seed: assign sortOrder within each group by tag_id
|
||||
WITH ordered AS (
|
||||
SELECT "tag_id",
|
||||
ROW_NUMBER() OVER (PARTITION BY "tag_group_id" ORDER BY "tag_id") AS rn
|
||||
FROM "tags"
|
||||
WHERE "tag_group_id" IS NOT NULL
|
||||
)
|
||||
UPDATE "tags" SET "sort_order" = ordered.rn
|
||||
FROM ordered
|
||||
WHERE "tags"."tag_id" = ordered."tag_id";
|
||||
-- AlterTable
|
||||
ALTER TABLE "tags" ADD COLUMN "tag_group_id" BIGINT,
|
||||
ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tag_groups" (
|
||||
"tag_group_id" BIGSERIAL NOT NULL,
|
||||
"group_name" TEXT NOT NULL,
|
||||
"group_icon" TEXT,
|
||||
"group_color" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tag_groups_pkey" PRIMARY KEY ("tag_group_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tag_groups_group_name_key" ON "tag_groups"("group_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tags_tag_group_id_idx" ON "tags"("tag_group_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tags_tag_group_id_sort_order_idx" ON "tags"("tag_group_id", "sort_order");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tags" ADD CONSTRAINT "tags_tag_group_id_fkey" FOREIGN KEY ("tag_group_id") REFERENCES "tag_groups"("tag_group_id") ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
|
||||
-- Seed: insert 3 default tag groups
|
||||
INSERT INTO "tag_groups" ("group_name", "sort_order", "created_at", "updated_at") VALUES
|
||||
('物流渠道', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('印刷位置', 2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('印刷工艺', 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
|
||||
-- Seed: assign existing tags to groups
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '物流渠道')
|
||||
WHERE "tag_name" IN ('包邮', '不包邮');
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷位置')
|
||||
WHERE "tag_name" IN ('单面印', '双面印');
|
||||
UPDATE "tags" SET "tag_group_id" = (SELECT "tag_group_id" FROM "tag_groups" WHERE "group_name" = '印刷工艺')
|
||||
WHERE "tag_name" IN ('烫画', '直喷', '不打印');
|
||||
|
||||
-- Seed: assign sortOrder within each group by tag_id
|
||||
WITH ordered AS (
|
||||
SELECT "tag_id",
|
||||
ROW_NUMBER() OVER (PARTITION BY "tag_group_id" ORDER BY "tag_id") AS rn
|
||||
FROM "tags"
|
||||
WHERE "tag_group_id" IS NOT NULL
|
||||
)
|
||||
UPDATE "tags" SET "sort_order" = ordered.rn
|
||||
FROM ordered
|
||||
WHERE "tags"."tag_id" = ordered."tag_id";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "origin_goods" ADD COLUMN "delisted" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Sync database with schema.prisma (missing columns/table from earlier iterations)
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "goods" ADD COLUMN "good_image" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "tags" ADD COLUMN "tag_font_color" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "good_tags" (
|
||||
"good_id" BIGINT NOT NULL,
|
||||
"tag_id" BIGINT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "good_tags_pkey" PRIMARY KEY ("good_id","tag_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "good_tags_tag_id_idx" ON "good_tags"("tag_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
ALTER TABLE "good_tags" ADD CONSTRAINT "good_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -252,7 +253,7 @@ model User {
|
||||
role Role @default(ADMIN)
|
||||
// Bumped on logout / revocation; JWTs carrying an older version are rejected.
|
||||
tokenVersion Int @default(0) @map("token_version")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("users")
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="毛毯">
|
||||
<path id="Vector" d="M4.88633 1.98665C5.33119 1.97156 5.8398 1.98301 6.28931 1.98301L8.87332 1.98303L16.492 1.98247C17.189 1.98315 17.7065 2.1872 18.0772 2.81271C18.3143 3.2131 18.2842 3.61555 18.2841 4.05998L18.2832 4.9648C18.2977 4.96406 18.3123 4.96311 18.327 4.96317C18.946 4.96544 19.5651 4.96377 20.1842 4.96464C20.35 4.96487 20.5158 4.9649 20.6812 4.97965C21.3291 5.03911 21.9365 5.32106 22.4002 5.77761C22.9854 6.35146 23.2455 7.05899 23.2511 7.8691L23.2513 15.9239L23.2516 18.1581C23.2517 18.5872 23.2748 19.2749 23.1974 19.6785C23.0883 20.23 22.8164 20.7362 22.417 21.1318C22.2073 21.3409 21.9682 21.518 21.7072 21.6575C20.8557 22.1159 20.0448 22.0235 19.1195 22.0226H8.94568L6.28116 22.0228C5.66064 22.0231 4.88519 22.0568 4.28608 21.9464C3.4868 21.8008 2.74539 21.4309 2.14822 20.88C1.30557 20.1054 0.806162 19.027 0.760423 17.8834C0.744748 17.5372 0.750489 17.1759 0.750662 16.8281L0.750771 15.2893L0.750812 10.4876L0.750558 7.56548C0.750482 6.91186 0.712701 6.06771 0.837323 5.44551C0.997082 4.66425 1.36911 3.94214 1.91253 3.35852C2.68497 2.52818 3.75329 2.03535 4.88633 1.98665ZM1.50862 15.253C2.14832 14.3061 3.14214 13.6566 4.26615 13.4506C4.77379 13.3528 5.22789 13.3733 5.74026 13.3733L7.22112 13.3737L12.4984 13.3738L15.4964 13.3742L16.3552 13.3732C16.8991 13.3726 17.0379 13.3561 17.5334 13.5986V6.61544V4.56193C17.5335 4.30358 17.549 3.72192 17.5225 3.49302C17.5006 3.30198 17.4139 3.12419 17.2769 2.98926C16.9857 2.69911 16.6673 2.73987 16.2868 2.74018L15.5406 2.7411L13.0675 2.74118L5.14866 2.74166C5.07448 2.74247 5.00032 2.74432 4.92619 2.7472C3.92002 2.80855 3.077 3.17975 2.40127 3.93833C1.97103 4.42125 1.68113 5.01268 1.563 5.64857C1.47184 6.14183 1.50058 6.87789 1.50091 7.39632L1.50111 9.95416L1.50107 13.4367L1.50105 14.5679C1.50109 14.7909 1.49296 15.0324 1.50862 15.253ZM8.99453 18.9369L16.2925 18.9372L18.6071 18.9366L19.3445 18.9363C19.5484 18.9363 19.9029 18.898 20.056 19.0438C20.1299 19.1145 20.1708 19.2128 20.1691 19.3151C20.1667 19.4209 20.1207 19.5211 20.0418 19.5917C19.9477 19.6764 19.8889 19.6822 19.7676 19.6861C19.4157 19.6972 19.0567 19.6923 18.7049 19.6923L16.8063 19.6924H11.0456L6.92691 19.6928L5.64741 19.6927C5.38641 19.6927 5.09412 19.7061 4.83641 19.6802C3.90898 19.587 3.25249 18.7671 3.34189 17.8533C3.38568 17.4162 3.6029 17.0151 3.94493 16.7394C4.20443 16.5318 4.51749 16.402 4.84778 16.3651C5.04529 16.3417 5.27545 16.3472 5.47726 16.3473L6.33113 16.3475L9.20144 16.3477L14.7026 16.3475C15.6297 16.3475 16.6073 16.3314 17.5311 16.3491L17.5349 15.4343C17.535 15.2346 17.5476 14.9099 17.5039 14.73C17.4709 14.5924 17.402 14.4661 17.3042 14.3639C17.0397 14.0929 16.7182 14.1312 16.371 14.1312L15.6384 14.1317H13.1882L5.32143 14.1324C5.24029 14.1331 5.15918 14.1352 5.07812 14.1387C4.01313 14.198 3.10203 14.573 2.38161 15.3836C1.76329 16.0772 1.44917 16.99 1.50965 17.9172C1.56819 18.8651 2.00149 19.7507 2.71393 20.3786C3.22367 20.8289 3.85296 21.1221 4.52573 21.2224C4.85955 21.2734 5.19037 21.2625 5.52735 21.2625L6.74896 21.2624L11.0247 21.2619L17.3304 21.2618L19.3445 21.2623C19.7137 21.2624 20.0973 21.2727 20.4665 21.2537C21.0698 21.2227 21.64 20.8929 22.0275 20.4408C22.7492 19.599 22.6019 18.3111 21.764 17.5974C21.451 17.33 21.0641 17.164 20.6547 17.1215C20.4169 17.0944 20.0379 17.1047 19.7895 17.1046H18.3545L13.6232 17.105L7.80963 17.1052L5.98936 17.1046C5.66054 17.1045 5.3165 17.0978 4.98927 17.1128C4.72169 17.1251 4.46675 17.2485 4.29114 17.4549C4.13868 17.6329 4.06436 17.8648 4.08495 18.0982C4.10752 18.3431 4.22707 18.5688 4.41697 18.725C4.72065 18.9786 5.05403 18.9374 5.4256 18.9375H6.19901L8.99453 18.9369ZM18.2839 16.3473C18.5978 16.3331 19.0591 16.3473 19.3878 16.3477C19.8266 16.3481 20.4738 16.3234 20.8846 16.3909C21.1822 16.4405 21.4698 16.5372 21.7369 16.6773C21.9299 16.7794 22.1104 16.9035 22.2749 17.0472C22.3199 17.0859 22.4581 17.2153 22.4969 17.2411C22.5077 16.9956 22.5012 16.7118 22.501 16.4625L22.5009 15.1418L22.5006 11.0416L22.5007 8.79514C22.5011 8.43763 22.5203 7.7808 22.4675 7.45223C22.403 7.05879 22.2247 6.69285 21.9545 6.3996C21.09 5.45927 20.0243 5.7749 18.8906 5.72076C18.7008 5.71171 18.4688 5.7379 18.2855 5.71609C18.2818 6.29028 18.2808 6.8645 18.2829 7.4387L18.2832 10.5551L18.2839 16.3473Z" fill="var(--fill-0, black)"/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="毛毯">
|
||||
<path id="Vector" d="M4.88633 1.98665C5.33119 1.97156 5.8398 1.98301 6.28931 1.98301L8.87332 1.98303L16.492 1.98247C17.189 1.98315 17.7065 2.1872 18.0772 2.81271C18.3143 3.2131 18.2842 3.61555 18.2841 4.05998L18.2832 4.9648C18.2977 4.96406 18.3123 4.96311 18.327 4.96317C18.946 4.96544 19.5651 4.96377 20.1842 4.96464C20.35 4.96487 20.5158 4.9649 20.6812 4.97965C21.3291 5.03911 21.9365 5.32106 22.4002 5.77761C22.9854 6.35146 23.2455 7.05899 23.2511 7.8691L23.2513 15.9239L23.2516 18.1581C23.2517 18.5872 23.2748 19.2749 23.1974 19.6785C23.0883 20.23 22.8164 20.7362 22.417 21.1318C22.2073 21.3409 21.9682 21.518 21.7072 21.6575C20.8557 22.1159 20.0448 22.0235 19.1195 22.0226H8.94568L6.28116 22.0228C5.66064 22.0231 4.88519 22.0568 4.28608 21.9464C3.4868 21.8008 2.74539 21.4309 2.14822 20.88C1.30557 20.1054 0.806162 19.027 0.760423 17.8834C0.744748 17.5372 0.750489 17.1759 0.750662 16.8281L0.750771 15.2893L0.750812 10.4876L0.750558 7.56548C0.750482 6.91186 0.712701 6.06771 0.837323 5.44551C0.997082 4.66425 1.36911 3.94214 1.91253 3.35852C2.68497 2.52818 3.75329 2.03535 4.88633 1.98665ZM1.50862 15.253C2.14832 14.3061 3.14214 13.6566 4.26615 13.4506C4.77379 13.3528 5.22789 13.3733 5.74026 13.3733L7.22112 13.3737L12.4984 13.3738L15.4964 13.3742L16.3552 13.3732C16.8991 13.3726 17.0379 13.3561 17.5334 13.5986V6.61544V4.56193C17.5335 4.30358 17.549 3.72192 17.5225 3.49302C17.5006 3.30198 17.4139 3.12419 17.2769 2.98926C16.9857 2.69911 16.6673 2.73987 16.2868 2.74018L15.5406 2.7411L13.0675 2.74118L5.14866 2.74166C5.07448 2.74247 5.00032 2.74432 4.92619 2.7472C3.92002 2.80855 3.077 3.17975 2.40127 3.93833C1.97103 4.42125 1.68113 5.01268 1.563 5.64857C1.47184 6.14183 1.50058 6.87789 1.50091 7.39632L1.50111 9.95416L1.50107 13.4367L1.50105 14.5679C1.50109 14.7909 1.49296 15.0324 1.50862 15.253ZM8.99453 18.9369L16.2925 18.9372L18.6071 18.9366L19.3445 18.9363C19.5484 18.9363 19.9029 18.898 20.056 19.0438C20.1299 19.1145 20.1708 19.2128 20.1691 19.3151C20.1667 19.4209 20.1207 19.5211 20.0418 19.5917C19.9477 19.6764 19.8889 19.6822 19.7676 19.6861C19.4157 19.6972 19.0567 19.6923 18.7049 19.6923L16.8063 19.6924H11.0456L6.92691 19.6928L5.64741 19.6927C5.38641 19.6927 5.09412 19.7061 4.83641 19.6802C3.90898 19.587 3.25249 18.7671 3.34189 17.8533C3.38568 17.4162 3.6029 17.0151 3.94493 16.7394C4.20443 16.5318 4.51749 16.402 4.84778 16.3651C5.04529 16.3417 5.27545 16.3472 5.47726 16.3473L6.33113 16.3475L9.20144 16.3477L14.7026 16.3475C15.6297 16.3475 16.6073 16.3314 17.5311 16.3491L17.5349 15.4343C17.535 15.2346 17.5476 14.9099 17.5039 14.73C17.4709 14.5924 17.402 14.4661 17.3042 14.3639C17.0397 14.0929 16.7182 14.1312 16.371 14.1312L15.6384 14.1317H13.1882L5.32143 14.1324C5.24029 14.1331 5.15918 14.1352 5.07812 14.1387C4.01313 14.198 3.10203 14.573 2.38161 15.3836C1.76329 16.0772 1.44917 16.99 1.50965 17.9172C1.56819 18.8651 2.00149 19.7507 2.71393 20.3786C3.22367 20.8289 3.85296 21.1221 4.52573 21.2224C4.85955 21.2734 5.19037 21.2625 5.52735 21.2625L6.74896 21.2624L11.0247 21.2619L17.3304 21.2618L19.3445 21.2623C19.7137 21.2624 20.0973 21.2727 20.4665 21.2537C21.0698 21.2227 21.64 20.8929 22.0275 20.4408C22.7492 19.599 22.6019 18.3111 21.764 17.5974C21.451 17.33 21.0641 17.164 20.6547 17.1215C20.4169 17.0944 20.0379 17.1047 19.7895 17.1046H18.3545L13.6232 17.105L7.80963 17.1052L5.98936 17.1046C5.66054 17.1045 5.3165 17.0978 4.98927 17.1128C4.72169 17.1251 4.46675 17.2485 4.29114 17.4549C4.13868 17.6329 4.06436 17.8648 4.08495 18.0982C4.10752 18.3431 4.22707 18.5688 4.41697 18.725C4.72065 18.9786 5.05403 18.9374 5.4256 18.9375H6.19901L8.99453 18.9369ZM18.2839 16.3473C18.5978 16.3331 19.0591 16.3473 19.3878 16.3477C19.8266 16.3481 20.4738 16.3234 20.8846 16.3909C21.1822 16.4405 21.4698 16.5372 21.7369 16.6773C21.9299 16.7794 22.1104 16.9035 22.2749 17.0472C22.3199 17.0859 22.4581 17.2153 22.4969 17.2411C22.5077 16.9956 22.5012 16.7118 22.501 16.4625L22.5009 15.1418L22.5006 11.0416L22.5007 8.79514C22.5011 8.43763 22.5203 7.7808 22.4675 7.45223C22.403 7.05879 22.2247 6.69285 21.9545 6.3996C21.09 5.45927 20.0243 5.7749 18.8906 5.72076C18.7008 5.71171 18.4688 5.7379 18.2855 5.71609C18.2818 6.29028 18.2808 6.8645 18.2829 7.4387L18.2832 10.5551L18.2839 16.3473Z" fill="var(--fill-0, black)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
@@ -1,8 +1,8 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16.9444 21.2465" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group 40">
|
||||
<path id="Vector" d="M4.60937 9.39516C4.83089 9.41263 5.19797 9.40155 5.42769 9.40155L6.94956 9.40133C8.71577 9.40094 10.5191 9.38265 12.2815 9.40662C12.9508 11.3307 14.3581 13.4287 15.5839 15.0446C15.8822 15.4378 16.7974 16.3807 16.9444 16.6953C16.6383 17.2507 15.7458 17.7321 15.2033 18.0528C14.5811 18.4207 14.5649 18.082 14.2723 17.5918C14.0964 17.2971 13.9313 16.9779 13.7623 16.6745L12.1526 13.8128C11.8994 13.3696 11.5575 12.7309 11.217 12.3736C11.7063 14.5828 12.9504 16.8791 14.0188 18.8723C14.1871 19.1861 13.9309 19.3898 13.694 19.5564C13.4559 19.7238 12.6714 20.2953 12.3918 20.2153C12.2449 20.0934 12.0742 19.5867 11.9879 19.4213C11.7179 18.9037 10.0267 14.7534 9.73361 14.6621C9.63034 14.8155 9.77541 15.2831 9.80986 15.4684C10.1336 17.2094 10.7771 18.8659 11.4514 20.4962C11.5693 20.9293 9.64682 21.1744 9.30522 21.2057C8.24188 21.3076 7.16888 21.2186 6.13686 20.9428C5.9456 20.8923 5.66846 20.8251 5.5108 20.6989C5.46617 20.6631 5.41691 20.5888 5.43238 20.5306C5.54204 20.1186 5.80851 19.6085 5.95323 19.214C6.38496 18.0368 7.18965 15.777 7.16438 14.5671C6.91075 14.8989 6.6099 15.5769 6.42632 15.9742C6.019 16.8664 5.62029 17.7625 5.23025 18.6625C5.02017 19.1417 4.74544 19.823 4.49277 20.2564C4.44175 20.2465 4.39117 20.2343 4.34122 20.2198C3.94495 20.1042 2.97693 19.496 2.76767 19.1178C2.92234 18.6804 3.44618 17.8446 3.66926 17.3829C4.44438 15.7788 5.25941 14.108 5.66802 12.3696C4.94019 13.1618 2.49025 18.0769 2.21709 18.2064C2.15024 18.238 2.05775 18.2215 1.99036 18.1973C1.55572 18.0414 0.201822 17.2085 0.0193916 16.8083C-0.1163 16.5107 0.497699 15.9933 0.69442 15.7758C2.28029 14.0222 3.77967 11.6073 4.60937 9.39516Z" fill="var(--fill-0, #000000)"/>
|
||||
<path id="Vector_2" d="M4.62793 0.0189548C4.95631 0.0052677 5.36925 0.0045545 5.69706 0.0201891C6.26604 0.0473438 5.85348 1.03493 6.14374 1.42184C6.3697 1.72304 6.60509 1.99478 6.84768 2.28177C7.27209 2.78427 7.69139 3.29099 8.10563 3.80189C9.04203 4.96817 9.85815 6.15223 10.759 7.30756L9.78755 7.31091L4.67883 7.30962C4.61824 6.73838 4.55875 6.30262 4.40945 5.73701C4.12639 4.6645 3.51922 3.65092 3.76517 2.51157C3.87777 1.99001 4.27615 1.65282 4.41615 1.17841C4.48472 0.946111 4.42237 0.412836 4.49314 0.150148C4.51632 0.0640753 4.55721 0.0571635 4.62793 0.0189548Z" fill="var(--fill-0, #000000)"/>
|
||||
<path id="Vector_3" d="M11.061 0.016756C11.3249 0.0125319 12.1975 -0.0521733 12.3509 0.104885C12.4479 0.279115 12.3539 0.945805 12.4893 1.27531C12.6708 1.71711 12.9577 1.96767 13.086 2.42878C13.4027 3.56613 12.7585 4.65918 12.4803 5.74364C12.3332 6.31713 12.2859 6.72258 12.2107 7.30762C11.8729 7.31264 11.5351 7.31283 11.1973 7.30816C11.1476 7.22593 11.0856 7.14044 11.0303 7.06084C10.5544 6.38636 10.0687 5.71895 9.57305 5.05887C9.31283 4.71083 8.96544 4.22429 8.67958 3.91519C8.93354 3.69005 10.7286 1.47014 10.7952 1.27473C10.9056 0.950687 10.8169 0.581355 10.8834 0.247187C10.9088 0.11967 10.9592 0.085164 11.061 0.016756Z" fill="var(--fill-0, #000000)"/>
|
||||
<path id="Vector_4" d="M4.75302 7.85951L12.2149 7.8591C12.2154 8.05077 12.2291 8.77411 12.2066 8.92294L12.1674 8.93525C10.0843 8.9821 7.97963 8.91638 5.8947 8.94109C5.51017 8.94565 5.11366 8.92598 4.73124 8.94817C4.77096 8.6908 4.75466 8.13501 4.75302 7.85951Z" fill="var(--fill-0, #000000)"/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16.9444 21.2465" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group 40">
|
||||
<path id="Vector" d="M4.60937 9.39516C4.83089 9.41263 5.19797 9.40155 5.42769 9.40155L6.94956 9.40133C8.71577 9.40094 10.5191 9.38265 12.2815 9.40662C12.9508 11.3307 14.3581 13.4287 15.5839 15.0446C15.8822 15.4378 16.7974 16.3807 16.9444 16.6953C16.6383 17.2507 15.7458 17.7321 15.2033 18.0528C14.5811 18.4207 14.5649 18.082 14.2723 17.5918C14.0964 17.2971 13.9313 16.9779 13.7623 16.6745L12.1526 13.8128C11.8994 13.3696 11.5575 12.7309 11.217 12.3736C11.7063 14.5828 12.9504 16.8791 14.0188 18.8723C14.1871 19.1861 13.9309 19.3898 13.694 19.5564C13.4559 19.7238 12.6714 20.2953 12.3918 20.2153C12.2449 20.0934 12.0742 19.5867 11.9879 19.4213C11.7179 18.9037 10.0267 14.7534 9.73361 14.6621C9.63034 14.8155 9.77541 15.2831 9.80986 15.4684C10.1336 17.2094 10.7771 18.8659 11.4514 20.4962C11.5693 20.9293 9.64682 21.1744 9.30522 21.2057C8.24188 21.3076 7.16888 21.2186 6.13686 20.9428C5.9456 20.8923 5.66846 20.8251 5.5108 20.6989C5.46617 20.6631 5.41691 20.5888 5.43238 20.5306C5.54204 20.1186 5.80851 19.6085 5.95323 19.214C6.38496 18.0368 7.18965 15.777 7.16438 14.5671C6.91075 14.8989 6.6099 15.5769 6.42632 15.9742C6.019 16.8664 5.62029 17.7625 5.23025 18.6625C5.02017 19.1417 4.74544 19.823 4.49277 20.2564C4.44175 20.2465 4.39117 20.2343 4.34122 20.2198C3.94495 20.1042 2.97693 19.496 2.76767 19.1178C2.92234 18.6804 3.44618 17.8446 3.66926 17.3829C4.44438 15.7788 5.25941 14.108 5.66802 12.3696C4.94019 13.1618 2.49025 18.0769 2.21709 18.2064C2.15024 18.238 2.05775 18.2215 1.99036 18.1973C1.55572 18.0414 0.201822 17.2085 0.0193916 16.8083C-0.1163 16.5107 0.497699 15.9933 0.69442 15.7758C2.28029 14.0222 3.77967 11.6073 4.60937 9.39516Z" fill="var(--fill-0, #000000)"/>
|
||||
<path id="Vector_2" d="M4.62793 0.0189548C4.95631 0.0052677 5.36925 0.0045545 5.69706 0.0201891C6.26604 0.0473438 5.85348 1.03493 6.14374 1.42184C6.3697 1.72304 6.60509 1.99478 6.84768 2.28177C7.27209 2.78427 7.69139 3.29099 8.10563 3.80189C9.04203 4.96817 9.85815 6.15223 10.759 7.30756L9.78755 7.31091L4.67883 7.30962C4.61824 6.73838 4.55875 6.30262 4.40945 5.73701C4.12639 4.6645 3.51922 3.65092 3.76517 2.51157C3.87777 1.99001 4.27615 1.65282 4.41615 1.17841C4.48472 0.946111 4.42237 0.412836 4.49314 0.150148C4.51632 0.0640753 4.55721 0.0571635 4.62793 0.0189548Z" fill="var(--fill-0, #000000)"/>
|
||||
<path id="Vector_3" d="M11.061 0.016756C11.3249 0.0125319 12.1975 -0.0521733 12.3509 0.104885C12.4479 0.279115 12.3539 0.945805 12.4893 1.27531C12.6708 1.71711 12.9577 1.96767 13.086 2.42878C13.4027 3.56613 12.7585 4.65918 12.4803 5.74364C12.3332 6.31713 12.2859 6.72258 12.2107 7.30762C11.8729 7.31264 11.5351 7.31283 11.1973 7.30816C11.1476 7.22593 11.0856 7.14044 11.0303 7.06084C10.5544 6.38636 10.0687 5.71895 9.57305 5.05887C9.31283 4.71083 8.96544 4.22429 8.67958 3.91519C8.93354 3.69005 10.7286 1.47014 10.7952 1.27473C10.9056 0.950687 10.8169 0.581355 10.8834 0.247187C10.9088 0.11967 10.9592 0.085164 11.061 0.016756Z" fill="var(--fill-0, #000000)"/>
|
||||
<path id="Vector_4" d="M4.75302 7.85951L12.2149 7.8591C12.2154 8.05077 12.2291 8.77411 12.2066 8.92294L12.1674 8.93525C10.0843 8.9821 7.97963 8.91638 5.8947 8.94109C5.51017 8.94565 5.11366 8.92598 4.73124 8.94817C4.77096 8.6908 4.75466 8.13501 4.75302 7.85951Z" fill="var(--fill-0, #000000)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Export all application tables from the local (source) database to a JSON
|
||||
* file, preserving column types for the matching import-data.mjs script.
|
||||
*
|
||||
* Usage (from apps/api, against the local DB in .env):
|
||||
* node scripts/export-data.mjs <output.json>
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
const TABLES = [
|
||||
'users',
|
||||
'countries',
|
||||
'categories',
|
||||
'tag_groups',
|
||||
'tags',
|
||||
'positions',
|
||||
'origin_goods',
|
||||
'origin_good_variants',
|
||||
'origin_good_details',
|
||||
'goods',
|
||||
'good_tags',
|
||||
'sync_logs',
|
||||
];
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Serialize values losslessly; import side uses information_schema to restore types.
|
||||
function serialize(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (typeof value === 'object' && Buffer.isBuffer(value)) return value.toString('base64');
|
||||
if (typeof value === 'object') return JSON.stringify(value); // jsonb
|
||||
return value;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const out = process.argv[2];
|
||||
if (!out) {
|
||||
console.error('Usage: node scripts/export-data.mjs <output.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dump = { exportedAt: new Date().toISOString(), tables: {} };
|
||||
for (const table of TABLES) {
|
||||
const rows = await prisma.$queryRawUnsafe(`SELECT * FROM "${table}"`);
|
||||
dump.tables[table] = rows.map((row) => {
|
||||
const o = {};
|
||||
for (const [k, v] of Object.entries(row)) o[k] = serialize(v);
|
||||
return o;
|
||||
});
|
||||
console.log(`${table}: ${rows.length} rows`);
|
||||
}
|
||||
|
||||
writeFileSync(out, JSON.stringify(dump));
|
||||
console.log(`Wrote ${out}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Import a dump produced by export-data.mjs into the current database.
|
||||
* Tables are truncated first (order-independent via session_replication_role)
|
||||
* and columns are cast back to their real types using information_schema.
|
||||
*
|
||||
* Usage (inside the api container):
|
||||
* node scripts/import-data.mjs <dump.json>
|
||||
*/
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function toLiteral(value, udtName) {
|
||||
if (value === null) return Prisma.sql`NULL`;
|
||||
const target = Prisma.raw(`"${udtName}"`);
|
||||
switch (udtName) {
|
||||
case 'int2':
|
||||
case 'int4':
|
||||
case 'int8':
|
||||
return Prisma.sql`${BigInt(value)}::${target}`;
|
||||
case 'float4':
|
||||
case 'float8':
|
||||
case 'numeric':
|
||||
return Prisma.sql`${Number(value)}::${target}`;
|
||||
case 'bool':
|
||||
return Prisma.sql`${!!value}::${target}`;
|
||||
case 'timestamptz':
|
||||
case 'timestamp':
|
||||
return Prisma.sql`${new Date(value).toISOString()}::${target}`;
|
||||
case 'date':
|
||||
return Prisma.sql`${String(value)}::${target}`;
|
||||
case 'jsonb':
|
||||
case 'json':
|
||||
return Prisma.sql`${typeof value === 'string' ? value : JSON.stringify(value)}::${target}`;
|
||||
case 'bytea':
|
||||
return Prisma.sql`${Buffer.from(value, 'base64')}::bytea`;
|
||||
default:
|
||||
// text, varchar, enums and anything else: pass as text and cast
|
||||
return Prisma.sql`${String(value)}::${target}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error('Usage: node scripts/import-data.mjs <dump.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
const dump = JSON.parse(readFileSync(file, 'utf8'));
|
||||
|
||||
// Suspend FK checks during bulk load (postgres superuser not required for
|
||||
// session_replication_role in the compose postgres where app user owns db).
|
||||
await prisma.$executeRawUnsafe(`SET session_replication_role = replica`);
|
||||
|
||||
const summary = {};
|
||||
for (const [table, rows] of Object.entries(dump.tables)) {
|
||||
if (rows.length === 0) {
|
||||
summary[table] = 0;
|
||||
continue;
|
||||
}
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
|
||||
|
||||
const colTypes = {};
|
||||
const info = await prisma.$queryRawUnsafe(
|
||||
`SELECT column_name, udt_name FROM information_schema.columns WHERE table_name = '${table}'`,
|
||||
);
|
||||
for (const c of info) colTypes[c.column_name] = c.udt_name;
|
||||
|
||||
const columns = Object.keys(rows[0]);
|
||||
const colList = Prisma.raw(columns.map((c) => `"${c}"`).join(', '));
|
||||
const CHUNK = 200;
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const tuples = rows.slice(i, i + CHUNK).map(
|
||||
(r) =>
|
||||
Prisma.sql`(${Prisma.join(
|
||||
columns.map((c) => toLiteral(r[c], colTypes[c])),
|
||||
)})`,
|
||||
);
|
||||
await prisma.$executeRaw(
|
||||
Prisma.sql`INSERT INTO ${Prisma.raw(`"${table}"`)} (${colList}) VALUES ${Prisma.join(tuples)}`,
|
||||
);
|
||||
}
|
||||
// Keep sequences ahead of imported serial ids
|
||||
// Keep serial sequences ahead of imported ids
|
||||
const idCol = columns.find(
|
||||
(c) => c === 'id' || c === `${table.replace(/s$/, '')}_id`,
|
||||
);
|
||||
if (idCol) {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`SELECT setval(pg_get_serial_sequence('"${table}"', '${idCol}'), COALESCE((SELECT MAX("${idCol}") FROM "${table}"), 1))`,
|
||||
);
|
||||
}
|
||||
summary[table] = rows.length;
|
||||
}
|
||||
|
||||
await prisma.$executeRawUnsafe(`SET session_replication_role = DEFAULT`);
|
||||
console.log('Imported:', JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Set the admin credentials for the production deployment:
|
||||
* rename/disable any existing admin and create/update user `inkreach`.
|
||||
*
|
||||
* Usage (inside the api container): node scripts/set-admin.mjs
|
||||
* Reads NEW_ADMIN_USER / NEW_ADMIN_PASSWORD from env.
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const username = process.env.NEW_ADMIN_USER;
|
||||
const password = process.env.NEW_ADMIN_PASSWORD;
|
||||
if (!username || !password) {
|
||||
console.error('NEW_ADMIN_USER / NEW_ADMIN_PASSWORD must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
await prisma.user.upsert({
|
||||
where: { username },
|
||||
create: { username, passwordHash },
|
||||
update: { passwordHash, tokenVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
// Remove every other admin so only `inkreach` can sign in.
|
||||
const others = await prisma.user.deleteMany({
|
||||
where: { username: { not: username } },
|
||||
});
|
||||
|
||||
const total = await prisma.user.count();
|
||||
console.log(`Admin '${username}' set. Demoted ${others.count} other user(s). Total users: ${total}.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -1,67 +1,67 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
|
||||
@ApiTags('categories')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly service: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Return categories as a tree' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get('flat')
|
||||
@ApiOperation({ summary: 'Return categories as a flat list' })
|
||||
findFlat() {
|
||||
return this.service.findFlat();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one category' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a category' })
|
||||
create(@Body() dto: CreateCategoryDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a category' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateCategoryDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a category' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
|
||||
@ApiTags('categories')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly service: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Return categories as a tree' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get('flat')
|
||||
@ApiOperation({ summary: 'Return categories as a flat list' })
|
||||
findFlat() {
|
||||
return this.service.findFlat();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one category' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a category' })
|
||||
create(@Body() dto: CreateCategoryDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a category' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateCategoryDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a category' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('CategoriesService', () => {
|
||||
let service: CategoriesService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [CategoriesService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(CategoriesService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.category.deleteMany({
|
||||
where: { categoryName: { in: createdNames } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates root + child + grandchild and assembles them into a tree', async () => {
|
||||
const stamp = Date.now();
|
||||
const rootName = `Root ${stamp}`;
|
||||
const childName = `Child ${stamp}`;
|
||||
const leafName = `Leaf ${stamp}`;
|
||||
createdNames.push(rootName, childName, leafName);
|
||||
|
||||
const root = await service.create({ categoryName: rootName });
|
||||
const child = await service.create({
|
||||
categoryName: childName,
|
||||
parentCategoryId: Number(root.id),
|
||||
});
|
||||
const leaf = await service.create({
|
||||
categoryName: leafName,
|
||||
parentCategoryId: Number(child.id),
|
||||
});
|
||||
|
||||
const tree = await service.findAll();
|
||||
const findNode = (
|
||||
list: Array<{ id: string; children: Array<{ id: string }> }>,
|
||||
id: string,
|
||||
): { id: string; children: Array<{ id: string }> } | undefined => {
|
||||
for (const n of list) {
|
||||
if (n.id === id) return n;
|
||||
const inner = findNode(n.children as any, id);
|
||||
if (inner) return inner;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const rootNode = findNode(tree as any, root.id.toString());
|
||||
expect(rootNode).toBeDefined();
|
||||
const childNode = findNode(rootNode!.children as any, child.id.toString());
|
||||
expect(childNode).toBeDefined();
|
||||
const leafNode = findNode(childNode!.children as any, leaf.id.toString());
|
||||
expect(leafNode).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects deletion when children exist', async () => {
|
||||
const stamp = Date.now();
|
||||
const parent = await service.create({ categoryName: `Parent ${stamp}` });
|
||||
createdNames.push(parent.categoryName);
|
||||
const child = await service.create({
|
||||
categoryName: `Child of ${stamp}`,
|
||||
parentCategoryId: Number(parent.id),
|
||||
});
|
||||
createdNames.push(child.categoryName);
|
||||
|
||||
await expect(service.remove(parent.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a flat list and a single node', async () => {
|
||||
const stamp = Date.now();
|
||||
const c = await service.create({ categoryName: `Flat ${stamp}` });
|
||||
createdNames.push(c.categoryName);
|
||||
const flat = await service.findFlat();
|
||||
expect(flat.some((row) => row.id === c.id)).toBe(true);
|
||||
const single = await service.findOne(c.id);
|
||||
expect(single.categoryName).toBe(c.categoryName);
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('CategoriesService', () => {
|
||||
let service: CategoriesService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [CategoriesService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(CategoriesService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.category.deleteMany({
|
||||
where: { categoryName: { in: createdNames } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates root + child + grandchild and assembles them into a tree', async () => {
|
||||
const stamp = Date.now();
|
||||
const rootName = `Root ${stamp}`;
|
||||
const childName = `Child ${stamp}`;
|
||||
const leafName = `Leaf ${stamp}`;
|
||||
createdNames.push(rootName, childName, leafName);
|
||||
|
||||
const root = await service.create({ categoryName: rootName });
|
||||
const child = await service.create({
|
||||
categoryName: childName,
|
||||
parentCategoryId: Number(root.id),
|
||||
});
|
||||
const leaf = await service.create({
|
||||
categoryName: leafName,
|
||||
parentCategoryId: Number(child.id),
|
||||
});
|
||||
|
||||
const tree = await service.findAll();
|
||||
const findNode = (
|
||||
list: Array<{ id: string; children: Array<{ id: string }> }>,
|
||||
id: string,
|
||||
): { id: string; children: Array<{ id: string }> } | undefined => {
|
||||
for (const n of list) {
|
||||
if (n.id === id) return n;
|
||||
const inner = findNode(n.children as any, id);
|
||||
if (inner) return inner;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const rootNode = findNode(tree as any, root.id.toString());
|
||||
expect(rootNode).toBeDefined();
|
||||
const childNode = findNode(rootNode!.children as any, child.id.toString());
|
||||
expect(childNode).toBeDefined();
|
||||
const leafNode = findNode(childNode!.children as any, leaf.id.toString());
|
||||
expect(leafNode).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects deletion when children exist', async () => {
|
||||
const stamp = Date.now();
|
||||
const parent = await service.create({ categoryName: `Parent ${stamp}` });
|
||||
createdNames.push(parent.categoryName);
|
||||
const child = await service.create({
|
||||
categoryName: `Child of ${stamp}`,
|
||||
parentCategoryId: Number(parent.id),
|
||||
});
|
||||
createdNames.push(child.categoryName);
|
||||
|
||||
await expect(service.remove(parent.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a flat list and a single node', async () => {
|
||||
const stamp = Date.now();
|
||||
const c = await service.create({ categoryName: `Flat ${stamp}` });
|
||||
createdNames.push(c.categoryName);
|
||||
const flat = await service.findFlat();
|
||||
expect(flat.some((row) => row.id === c.id)).toBe(true);
|
||||
const single = await service.findOne(c.id);
|
||||
expect(single.categoryName).toBe(c.categoryName);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,137 +1,137 @@
|
||||
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
import { CategoryNodeDto } from './dto/category-node.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(): Promise<CategoryNodeDto[]> {
|
||||
const all = await this.prisma.category.findMany({
|
||||
orderBy: [{ id: 'asc' }],
|
||||
});
|
||||
return this.buildTree(all);
|
||||
}
|
||||
|
||||
async findFlat() {
|
||||
return this.prisma.category.findMany({
|
||||
orderBy: [{ id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id } });
|
||||
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
||||
return c;
|
||||
}
|
||||
|
||||
async create(dto: CreateCategoryDto) {
|
||||
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||
// Validate the parent exists to produce a clean 404 instead of FK error.
|
||||
await this.findOne(BigInt(dto.parentCategoryId));
|
||||
}
|
||||
return this.prisma.category.create({
|
||||
data: {
|
||||
categoryName: dto.categoryName,
|
||||
categoryIcon: dto.categoryIcon ?? null,
|
||||
parentCategoryId:
|
||||
dto.parentCategoryId === undefined || dto.parentCategoryId === null
|
||||
? null
|
||||
: BigInt(dto.parentCategoryId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateCategoryDto) {
|
||||
await this.findOne(id);
|
||||
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||
// Prevent self-parenting & cycles.
|
||||
if (BigInt(dto.parentCategoryId) === id) {
|
||||
throw new BadRequestException('A category cannot be its own parent');
|
||||
}
|
||||
await this.findOne(BigInt(dto.parentCategoryId));
|
||||
}
|
||||
const data: Prisma.CategoryUpdateInput = {};
|
||||
if (dto.categoryName !== undefined) data.categoryName = dto.categoryName;
|
||||
if (dto.categoryIcon !== undefined) data.categoryIcon = dto.categoryIcon;
|
||||
if (dto.parentCategoryId !== undefined) {
|
||||
data.parent = dto.parentCategoryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.parentCategoryId) } };
|
||||
}
|
||||
return this.prisma.category.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
const childCount = await this.prisma.category.count({
|
||||
where: { parentCategoryId: id },
|
||||
});
|
||||
if (childCount > 0) {
|
||||
throw new BadRequestException(
|
||||
'Category has children and cannot be deleted',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await this.prisma.category.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (this.isForeignKeyViolation(err)) {
|
||||
throw new BadRequestException(
|
||||
'Category is referenced by goods or positions and cannot be deleted',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private isForeignKeyViolation(err: unknown): boolean {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return err.code === 'P2003';
|
||||
}
|
||||
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||
const msg = err.message ?? '';
|
||||
return (
|
||||
msg.includes('foreign key constraint') ||
|
||||
msg.includes('RESTRICT') ||
|
||||
msg.includes('violates')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a tree in-memory from a flat list. Top-level nodes have
|
||||
* `parentCategoryId = null`.
|
||||
*/
|
||||
private buildTree(
|
||||
rows: PrismaCategory[],
|
||||
): CategoryNodeDto[] {
|
||||
const byId = new Map<bigint, CategoryNodeDto>();
|
||||
for (const row of rows) {
|
||||
byId.set(row.id, CategoryNodeDto.from(row, []));
|
||||
}
|
||||
const roots: CategoryNodeDto[] = [];
|
||||
for (const row of rows) {
|
||||
const node = byId.get(row.id)!;
|
||||
if (row.parentCategoryId === null) {
|
||||
roots.push(node);
|
||||
} else {
|
||||
const parent = byId.get(row.parentCategoryId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
// Orphan (parent row missing) — surface as a root.
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
import { CategoryNodeDto } from './dto/category-node.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(): Promise<CategoryNodeDto[]> {
|
||||
const all = await this.prisma.category.findMany({
|
||||
orderBy: [{ id: 'asc' }],
|
||||
});
|
||||
return this.buildTree(all);
|
||||
}
|
||||
|
||||
async findFlat() {
|
||||
return this.prisma.category.findMany({
|
||||
orderBy: [{ id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id } });
|
||||
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
||||
return c;
|
||||
}
|
||||
|
||||
async create(dto: CreateCategoryDto) {
|
||||
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||
// Validate the parent exists to produce a clean 404 instead of FK error.
|
||||
await this.findOne(BigInt(dto.parentCategoryId));
|
||||
}
|
||||
return this.prisma.category.create({
|
||||
data: {
|
||||
categoryName: dto.categoryName,
|
||||
categoryIcon: dto.categoryIcon ?? null,
|
||||
parentCategoryId:
|
||||
dto.parentCategoryId === undefined || dto.parentCategoryId === null
|
||||
? null
|
||||
: BigInt(dto.parentCategoryId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateCategoryDto) {
|
||||
await this.findOne(id);
|
||||
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||
// Prevent self-parenting & cycles.
|
||||
if (BigInt(dto.parentCategoryId) === id) {
|
||||
throw new BadRequestException('A category cannot be its own parent');
|
||||
}
|
||||
await this.findOne(BigInt(dto.parentCategoryId));
|
||||
}
|
||||
const data: Prisma.CategoryUpdateInput = {};
|
||||
if (dto.categoryName !== undefined) data.categoryName = dto.categoryName;
|
||||
if (dto.categoryIcon !== undefined) data.categoryIcon = dto.categoryIcon;
|
||||
if (dto.parentCategoryId !== undefined) {
|
||||
data.parent = dto.parentCategoryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.parentCategoryId) } };
|
||||
}
|
||||
return this.prisma.category.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
const childCount = await this.prisma.category.count({
|
||||
where: { parentCategoryId: id },
|
||||
});
|
||||
if (childCount > 0) {
|
||||
throw new BadRequestException(
|
||||
'Category has children and cannot be deleted',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await this.prisma.category.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (this.isForeignKeyViolation(err)) {
|
||||
throw new BadRequestException(
|
||||
'Category is referenced by goods or positions and cannot be deleted',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private isForeignKeyViolation(err: unknown): boolean {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return err.code === 'P2003';
|
||||
}
|
||||
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||
const msg = err.message ?? '';
|
||||
return (
|
||||
msg.includes('foreign key constraint') ||
|
||||
msg.includes('RESTRICT') ||
|
||||
msg.includes('violates')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a tree in-memory from a flat list. Top-level nodes have
|
||||
* `parentCategoryId = null`.
|
||||
*/
|
||||
private buildTree(
|
||||
rows: PrismaCategory[],
|
||||
): CategoryNodeDto[] {
|
||||
const byId = new Map<bigint, CategoryNodeDto>();
|
||||
for (const row of rows) {
|
||||
byId.set(row.id, CategoryNodeDto.from(row, []));
|
||||
}
|
||||
const roots: CategoryNodeDto[] = [];
|
||||
for (const row of rows) {
|
||||
const node = byId.get(row.id)!;
|
||||
if (row.parentCategoryId === null) {
|
||||
roots.push(node);
|
||||
} else {
|
||||
const parent = byId.get(row.parentCategoryId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
// Orphan (parent row missing) — surface as a root.
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Category as PrismaCategory } from '@prisma/client';
|
||||
|
||||
export class CategoryNodeDto {
|
||||
@ApiProperty({ description: 'Category ID (bigint serialized as string)' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
categoryIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
sdsCategoryId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Parent category ID' })
|
||||
parentCategoryId!: string | null;
|
||||
|
||||
@ApiProperty({ type: [CategoryNodeDto] })
|
||||
children!: CategoryNodeDto[];
|
||||
|
||||
static from(category: PrismaCategory, children: CategoryNodeDto[] = []): CategoryNodeDto {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
categoryName: category.categoryName,
|
||||
categoryIcon: category.categoryIcon,
|
||||
sdsCategoryId: category.sdsCategoryId,
|
||||
parentCategoryId: category.parentCategoryId
|
||||
? category.parentCategoryId.toString()
|
||||
: null,
|
||||
children,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Category as PrismaCategory } from '@prisma/client';
|
||||
|
||||
export class CategoryNodeDto {
|
||||
@ApiProperty({ description: 'Category ID (bigint serialized as string)' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
categoryIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
sdsCategoryId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Parent category ID' })
|
||||
parentCategoryId!: string | null;
|
||||
|
||||
@ApiProperty({ type: [CategoryNodeDto] })
|
||||
children!: CategoryNodeDto[];
|
||||
|
||||
static from(category: PrismaCategory, children: CategoryNodeDto[] = []): CategoryNodeDto {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
categoryName: category.categoryName,
|
||||
categoryIcon: category.categoryIcon,
|
||||
sdsCategoryId: category.sdsCategoryId,
|
||||
parentCategoryId: category.parentCategoryId
|
||||
? category.parentCategoryId.toString()
|
||||
: null,
|
||||
children,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryIcon?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Parent category ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
parentCategoryId?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryIcon?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Parent category ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
parentCategoryId?: number;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateCategoryDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryIcon?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
parentCategoryId?: number | null;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateCategoryDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryIcon?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
parentCategoryId?: number | null;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Pulls the authenticated user out of the request, as populated by
|
||||
* the JWT strategy.
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: keyof { id: bigint; username: string } | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user?: { id: bigint; username: string } }>();
|
||||
const user = request.user;
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Pulls the authenticated user out of the request, as populated by
|
||||
* the JWT strategy.
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: keyof { id: bigint; username: string } | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user?: { id: bigint; username: string } }>();
|
||||
const user = request.user;
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,92 +1,92 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Global HTTP exception filter.
|
||||
*
|
||||
* Normalizes the error envelope to:
|
||||
* ```json
|
||||
* {
|
||||
* "statusCode": 400,
|
||||
* "message": "...",
|
||||
* "error": "...",
|
||||
* "timestamp": "ISO-8601",
|
||||
* "path": "..."
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
// Malformed bigint/number inputs (e.g. `BigInt("abc")`) are client
|
||||
// errors — map them to 400 instead of leaking a 500.
|
||||
if (
|
||||
status === HttpStatus.INTERNAL_SERVER_ERROR &&
|
||||
exception instanceof Error &&
|
||||
/Cannot convert .+ to (a BigInt|number)/i.test(exception.message)
|
||||
) {
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
message: 'Invalid numeric identifier',
|
||||
error: 'BadRequestError',
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let message: string | string[] = 'Internal server error';
|
||||
let error = 'InternalServerError';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const resp = exception.getResponse();
|
||||
if (typeof resp === 'string') {
|
||||
message = resp;
|
||||
} else if (typeof resp === 'object' && resp !== null) {
|
||||
const obj = resp as Record<string, unknown>;
|
||||
message = (obj.message as string | string[]) ?? exception.message;
|
||||
error = (obj.error as string) ?? exception.name;
|
||||
} else {
|
||||
message = exception.message;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
// Unexpected errors (Prisma, driver, ...) may contain SQL or
|
||||
// connection details — never send them to the client.
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} -> ${status} ${exception.message}`,
|
||||
exception.stack,
|
||||
);
|
||||
}
|
||||
|
||||
if (status >= 500 && exception instanceof HttpException) {
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} -> ${status} ${message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Global HTTP exception filter.
|
||||
*
|
||||
* Normalizes the error envelope to:
|
||||
* ```json
|
||||
* {
|
||||
* "statusCode": 400,
|
||||
* "message": "...",
|
||||
* "error": "...",
|
||||
* "timestamp": "ISO-8601",
|
||||
* "path": "..."
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
// Malformed bigint/number inputs (e.g. `BigInt("abc")`) are client
|
||||
// errors — map them to 400 instead of leaking a 500.
|
||||
if (
|
||||
status === HttpStatus.INTERNAL_SERVER_ERROR &&
|
||||
exception instanceof Error &&
|
||||
/Cannot convert .+ to (a BigInt|number)/i.test(exception.message)
|
||||
) {
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
message: 'Invalid numeric identifier',
|
||||
error: 'BadRequestError',
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let message: string | string[] = 'Internal server error';
|
||||
let error = 'InternalServerError';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const resp = exception.getResponse();
|
||||
if (typeof resp === 'string') {
|
||||
message = resp;
|
||||
} else if (typeof resp === 'object' && resp !== null) {
|
||||
const obj = resp as Record<string, unknown>;
|
||||
message = (obj.message as string | string[]) ?? exception.message;
|
||||
error = (obj.error as string) ?? exception.name;
|
||||
} else {
|
||||
message = exception.message;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
// Unexpected errors (Prisma, driver, ...) may contain SQL or
|
||||
// connection details — never send them to the client.
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} -> ${status} ${exception.message}`,
|
||||
exception.stack,
|
||||
);
|
||||
}
|
||||
|
||||
if (status >= 500 && exception instanceof HttpException) {
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} -> ${status} ${message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Wraps every successful response into `{ data, success: true }`.
|
||||
*
|
||||
* Exceptions still flow through the global filter and are not wrapped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(map((data) => ({ data, success: true })));
|
||||
}
|
||||
}
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Wraps every successful response into `{ data, success: true }`.
|
||||
*
|
||||
* Exceptions still flow through the global filter and are not wrapped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(map((data) => ({ data, success: true })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CountriesService } from './countries.service';
|
||||
import { CreateCountryDto } from './dto/create-country.dto';
|
||||
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||
|
||||
@ApiTags('countries')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('countries')
|
||||
export class CountriesController {
|
||||
constructor(private readonly service: CountriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all countries' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one country' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a country' })
|
||||
create(@Body() dto: CreateCountryDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a country' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateCountryDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a country' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CountriesService } from './countries.service';
|
||||
import { CreateCountryDto } from './dto/create-country.dto';
|
||||
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||
|
||||
@ApiTags('countries')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('countries')
|
||||
export class CountriesController {
|
||||
constructor(private readonly service: CountriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all countries' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one country' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a country' })
|
||||
create(@Body() dto: CreateCountryDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a country' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateCountryDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a country' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CountriesController } from './countries.controller';
|
||||
import { CountriesService } from './countries.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CountriesController],
|
||||
providers: [CountriesService],
|
||||
exports: [CountriesService],
|
||||
})
|
||||
export class CountriesModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CountriesController } from './countries.controller';
|
||||
import { CountriesService } from './countries.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CountriesController],
|
||||
providers: [CountriesService],
|
||||
exports: [CountriesService],
|
||||
})
|
||||
export class CountriesModule {}
|
||||
|
||||
@@ -1,115 +1,115 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CountriesService } from './countries.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('CountriesService', () => {
|
||||
let service: CountriesService;
|
||||
let prisma: PrismaService;
|
||||
const created: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [CountriesService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(CountriesService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (created.length) {
|
||||
// Delete goods/origin_goods first so we can remove the countries.
|
||||
await prisma.good.deleteMany({
|
||||
where: { country: { countryName: { in: created } } },
|
||||
});
|
||||
await prisma.position.deleteMany({
|
||||
where: { country: { countryName: { in: created } } },
|
||||
});
|
||||
await prisma.country.deleteMany({
|
||||
where: { countryName: { in: created } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates and reads back a country', async () => {
|
||||
const name = `Test Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
|
||||
const createdRow = await service.create({ countryName: name });
|
||||
expect(createdRow.countryName).toBe(name);
|
||||
|
||||
const fetched = await service.findOne(createdRow.id);
|
||||
expect(fetched.countryName).toBe(name);
|
||||
});
|
||||
|
||||
it('rejects duplicate names with ConflictException', async () => {
|
||||
const name = `Dup Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
await service.create({ countryName: name });
|
||||
await expect(service.create({ countryName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when deleting a country referenced by goods', async () => {
|
||||
const name = `Ref Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
const country = await service.create({ countryName: name });
|
||||
|
||||
// Need a category + origin good to satisfy the foreign keys before
|
||||
// we can attach a good that references the country.
|
||||
const category = await prisma.category.create({
|
||||
data: { categoryName: `Cat ${Date.now()}` },
|
||||
});
|
||||
const originGood = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `sds-${Date.now()}-${Math.random()}` },
|
||||
});
|
||||
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: originGood.id,
|
||||
countryId: country.id,
|
||||
categoryId: category.id,
|
||||
goodName: 'sample',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.remove(country.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
// cleanup
|
||||
await prisma.good.deleteMany({ where: { countryId: country.id } });
|
||||
await prisma.originGood.delete({ where: { id: originGood.id } });
|
||||
await prisma.category.delete({ where: { id: category.id } });
|
||||
});
|
||||
|
||||
it('updates fields and deletes when unreferenced', async () => {
|
||||
const name = `Upd Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
const c = await service.create({ countryName: name });
|
||||
const updated = await service.update(c.id, { countryName: `${name}-v2` });
|
||||
expect(updated.countryName).toBe(`${name}-v2`);
|
||||
created[created.indexOf(name)] = `${name}-v2`;
|
||||
|
||||
await service.remove(c.id);
|
||||
const idx = created.indexOf(`${name}-v2`);
|
||||
if (idx !== -1) created.splice(idx, 1);
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CountriesService } from './countries.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('CountriesService', () => {
|
||||
let service: CountriesService;
|
||||
let prisma: PrismaService;
|
||||
const created: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [CountriesService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(CountriesService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (created.length) {
|
||||
// Delete goods/origin_goods first so we can remove the countries.
|
||||
await prisma.good.deleteMany({
|
||||
where: { country: { countryName: { in: created } } },
|
||||
});
|
||||
await prisma.position.deleteMany({
|
||||
where: { country: { countryName: { in: created } } },
|
||||
});
|
||||
await prisma.country.deleteMany({
|
||||
where: { countryName: { in: created } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates and reads back a country', async () => {
|
||||
const name = `Test Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
|
||||
const createdRow = await service.create({ countryName: name });
|
||||
expect(createdRow.countryName).toBe(name);
|
||||
|
||||
const fetched = await service.findOne(createdRow.id);
|
||||
expect(fetched.countryName).toBe(name);
|
||||
});
|
||||
|
||||
it('rejects duplicate names with ConflictException', async () => {
|
||||
const name = `Dup Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
await service.create({ countryName: name });
|
||||
await expect(service.create({ countryName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when deleting a country referenced by goods', async () => {
|
||||
const name = `Ref Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
const country = await service.create({ countryName: name });
|
||||
|
||||
// Need a category + origin good to satisfy the foreign keys before
|
||||
// we can attach a good that references the country.
|
||||
const category = await prisma.category.create({
|
||||
data: { categoryName: `Cat ${Date.now()}` },
|
||||
});
|
||||
const originGood = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `sds-${Date.now()}-${Math.random()}` },
|
||||
});
|
||||
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: originGood.id,
|
||||
countryId: country.id,
|
||||
categoryId: category.id,
|
||||
goodName: 'sample',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.remove(country.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
// cleanup
|
||||
await prisma.good.deleteMany({ where: { countryId: country.id } });
|
||||
await prisma.originGood.delete({ where: { id: originGood.id } });
|
||||
await prisma.category.delete({ where: { id: category.id } });
|
||||
});
|
||||
|
||||
it('updates fields and deletes when unreferenced', async () => {
|
||||
const name = `Upd Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
const c = await service.create({ countryName: name });
|
||||
const updated = await service.update(c.id, { countryName: `${name}-v2` });
|
||||
expect(updated.countryName).toBe(`${name}-v2`);
|
||||
created[created.indexOf(name)] = `${name}-v2`;
|
||||
|
||||
await service.remove(c.id);
|
||||
const idx = created.indexOf(`${name}-v2`);
|
||||
if (idx !== -1) created.splice(idx, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCountryDto } from './dto/create-country.dto';
|
||||
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CountriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.country.findMany({ orderBy: { id: 'asc' } });
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const country = await this.prisma.country.findUnique({ where: { id } });
|
||||
if (!country) {
|
||||
throw new NotFoundException(`Country ${id} not found`);
|
||||
}
|
||||
return country;
|
||||
}
|
||||
|
||||
async create(dto: CreateCountryDto) {
|
||||
try {
|
||||
return await this.prisma.country.create({
|
||||
data: {
|
||||
countryName: dto.countryName,
|
||||
countryIcon: dto.countryIcon ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Country name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateCountryDto) {
|
||||
await this.findOne(id);
|
||||
try {
|
||||
return await this.prisma.country.update({
|
||||
where: { id },
|
||||
data: {
|
||||
countryName: dto.countryName,
|
||||
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Country name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
try {
|
||||
return await this.prisma.country.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (this.isForeignKeyViolation(err)) {
|
||||
throw new BadRequestException(
|
||||
'Country is referenced by goods or positions and cannot be deleted',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private isForeignKeyViolation(err: unknown): boolean {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
// P2003 = FK constraint violation
|
||||
return err.code === 'P2003';
|
||||
}
|
||||
// Fallback: Prisma sometimes surfaces FK violations as UnknownRequestError
|
||||
// when the constraint check happens server-side before the typed error
|
||||
// is mapped (e.g. cascading RESTRICT).
|
||||
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||
const msg = err.message ?? '';
|
||||
return (
|
||||
msg.includes('foreign key constraint') ||
|
||||
msg.includes('RESTRICT') ||
|
||||
msg.includes('violates')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCountryDto } from './dto/create-country.dto';
|
||||
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CountriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.country.findMany({ orderBy: { id: 'asc' } });
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const country = await this.prisma.country.findUnique({ where: { id } });
|
||||
if (!country) {
|
||||
throw new NotFoundException(`Country ${id} not found`);
|
||||
}
|
||||
return country;
|
||||
}
|
||||
|
||||
async create(dto: CreateCountryDto) {
|
||||
try {
|
||||
return await this.prisma.country.create({
|
||||
data: {
|
||||
countryName: dto.countryName,
|
||||
countryIcon: dto.countryIcon ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Country name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateCountryDto) {
|
||||
await this.findOne(id);
|
||||
try {
|
||||
return await this.prisma.country.update({
|
||||
where: { id },
|
||||
data: {
|
||||
countryName: dto.countryName,
|
||||
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Country name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
try {
|
||||
return await this.prisma.country.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (this.isForeignKeyViolation(err)) {
|
||||
throw new BadRequestException(
|
||||
'Country is referenced by goods or positions and cannot be deleted',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private isForeignKeyViolation(err: unknown): boolean {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
// P2003 = FK constraint violation
|
||||
return err.code === 'P2003';
|
||||
}
|
||||
// Fallback: Prisma sometimes surfaces FK violations as UnknownRequestError
|
||||
// when the constraint check happens server-side before the typed error
|
||||
// is mapped (e.g. cascading RESTRICT).
|
||||
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||
const msg = err.message ?? '';
|
||||
return (
|
||||
msg.includes('foreign key constraint') ||
|
||||
msg.includes('RESTRICT') ||
|
||||
msg.includes('violates')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateCountryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
countryName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryIcon?: string;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateCountryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
countryName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryIcon?: string;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateCountryDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryIcon?: string | null;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateCountryDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryIcon?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class BatchCreateItemDto {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId!: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export class BatchCreateGoodDto {
|
||||
@ApiProperty({ type: [BatchCreateItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BatchCreateItemDto)
|
||||
items!: BatchCreateItemDto[];
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
defaultPriority?: number;
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class BatchCreateItemDto {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId!: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export class BatchCreateGoodDto {
|
||||
@ApiProperty({ type: [BatchCreateItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BatchCreateItemDto)
|
||||
items!: BatchCreateItemDto[];
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
defaultPriority?: number;
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class PriorityItemDto {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
priority!: number;
|
||||
}
|
||||
|
||||
export class BatchPriorityDto {
|
||||
@ApiProperty({ type: [PriorityItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PriorityItemDto)
|
||||
items!: PriorityItemDto[];
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class PriorityItemDto {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
priority!: number;
|
||||
}
|
||||
|
||||
export class BatchPriorityDto {
|
||||
@ApiProperty({ type: [PriorityItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PriorityItemDto)
|
||||
items!: PriorityItemDto[];
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateGoodDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
goodPriority?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodImage?: string;
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateGoodDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
goodPriority?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodImage?: string;
|
||||
}
|
||||
+136
-136
@@ -1,17 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Good as PrismaGood } from '@prisma/client';
|
||||
|
||||
export interface GoodRelations {
|
||||
country?: { id: bigint; countryName: string; countryIcon: string | null } | null;
|
||||
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
|
||||
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
position?: { id: bigint; indexVal: number } | null;
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Good as PrismaGood } from '@prisma/client';
|
||||
|
||||
export interface GoodRelations {
|
||||
country?: { id: bigint; countryName: string; countryIcon: string | null } | null;
|
||||
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
|
||||
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
position?: { id: bigint; indexVal: number } | null;
|
||||
originGood?: {
|
||||
id: bigint;
|
||||
id: bigint;
|
||||
sdsGoodId: string;
|
||||
source: 'SDS' | 'CUSTOM';
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: unknown;
|
||||
detail?: {
|
||||
productCode: string | null;
|
||||
@@ -31,67 +31,67 @@ export interface GoodRelations {
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
_count?: { variants: number };
|
||||
} | null;
|
||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||
}
|
||||
|
||||
export class GoodDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
goodImage!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
goodPriority!: number;
|
||||
|
||||
@ApiProperty()
|
||||
countryId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryId!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
positionId!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
originGoodId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
country?: { id: string; countryName: string; countryIcon: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
category?: { id: string; categoryName: string; categoryIcon: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
tag?: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
position?: { id: string; indexVal: number } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
} | null;
|
||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||
}
|
||||
|
||||
export class GoodDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
goodImage!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
goodPriority!: number;
|
||||
|
||||
@ApiProperty()
|
||||
countryId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryId!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
positionId!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
originGoodId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
country?: { id: string; countryName: string; countryIcon: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
category?: { id: string; categoryName: string; categoryIcon: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
tag?: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
position?: { id: string; indexVal: number } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
originGood?: {
|
||||
id: string;
|
||||
id: string;
|
||||
sdsGoodId: string;
|
||||
source: 'SDS' | 'CUSTOM';
|
||||
isCustom: boolean;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
hasDetail: boolean;
|
||||
detailSyncedAt: string | null;
|
||||
@@ -99,72 +99,72 @@ export class GoodDto {
|
||||
sizeRowCount: number;
|
||||
packageRowCount: number;
|
||||
productCode: string | null;
|
||||
} | null;
|
||||
|
||||
static from(
|
||||
good: PrismaGood,
|
||||
rel: GoodRelations = {},
|
||||
): GoodDto {
|
||||
return {
|
||||
id: good.id.toString(),
|
||||
goodName: good.goodName,
|
||||
goodImage: good.goodImage,
|
||||
goodPriority: good.goodPriority,
|
||||
countryId: good.countryId.toString(),
|
||||
categoryId: good.categoryId.toString(),
|
||||
tagId: good.tagId === null || good.tagId === undefined ? null : good.tagId.toString(),
|
||||
positionId: good.positionId === null || good.positionId === undefined ? null : good.positionId.toString(),
|
||||
originGoodId: good.originGoodId.toString(),
|
||||
createdAt: good.createdAt.toISOString(),
|
||||
updatedAt: good.updatedAt.toISOString(),
|
||||
country: rel.country
|
||||
? {
|
||||
id: rel.country.id.toString(),
|
||||
countryName: rel.country.countryName,
|
||||
countryIcon: rel.country.countryIcon,
|
||||
}
|
||||
: null,
|
||||
category: rel.category
|
||||
? {
|
||||
id: rel.category.id.toString(),
|
||||
categoryName: rel.category.categoryName,
|
||||
categoryIcon: rel.category.categoryIcon,
|
||||
}
|
||||
: null,
|
||||
tag: rel.tag
|
||||
? {
|
||||
id: rel.tag.id.toString(),
|
||||
tagName: rel.tag.tagName,
|
||||
tagColor: rel.tag.tagColor,
|
||||
tagFontColor: rel.tag.tagFontColor,
|
||||
}
|
||||
: null,
|
||||
tags: rel.goodTags
|
||||
? rel.goodTags.map((gt) => ({
|
||||
id: gt.tag.id.toString(),
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
}))
|
||||
: [],
|
||||
position: rel.position
|
||||
? {
|
||||
id: rel.position.id.toString(),
|
||||
indexVal: rel.position.indexVal,
|
||||
}
|
||||
: null,
|
||||
} | null;
|
||||
|
||||
static from(
|
||||
good: PrismaGood,
|
||||
rel: GoodRelations = {},
|
||||
): GoodDto {
|
||||
return {
|
||||
id: good.id.toString(),
|
||||
goodName: good.goodName,
|
||||
goodImage: good.goodImage,
|
||||
goodPriority: good.goodPriority,
|
||||
countryId: good.countryId.toString(),
|
||||
categoryId: good.categoryId.toString(),
|
||||
tagId: good.tagId === null || good.tagId === undefined ? null : good.tagId.toString(),
|
||||
positionId: good.positionId === null || good.positionId === undefined ? null : good.positionId.toString(),
|
||||
originGoodId: good.originGoodId.toString(),
|
||||
createdAt: good.createdAt.toISOString(),
|
||||
updatedAt: good.updatedAt.toISOString(),
|
||||
country: rel.country
|
||||
? {
|
||||
id: rel.country.id.toString(),
|
||||
countryName: rel.country.countryName,
|
||||
countryIcon: rel.country.countryIcon,
|
||||
}
|
||||
: null,
|
||||
category: rel.category
|
||||
? {
|
||||
id: rel.category.id.toString(),
|
||||
categoryName: rel.category.categoryName,
|
||||
categoryIcon: rel.category.categoryIcon,
|
||||
}
|
||||
: null,
|
||||
tag: rel.tag
|
||||
? {
|
||||
id: rel.tag.id.toString(),
|
||||
tagName: rel.tag.tagName,
|
||||
tagColor: rel.tag.tagColor,
|
||||
tagFontColor: rel.tag.tagFontColor,
|
||||
}
|
||||
: null,
|
||||
tags: rel.goodTags
|
||||
? rel.goodTags.map((gt) => ({
|
||||
id: gt.tag.id.toString(),
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
}))
|
||||
: [],
|
||||
position: rel.position
|
||||
? {
|
||||
id: rel.position.id.toString(),
|
||||
indexVal: rel.position.indexVal,
|
||||
}
|
||||
: null,
|
||||
originGood: rel.originGood
|
||||
? {
|
||||
id: rel.originGood.id.toString(),
|
||||
id: rel.originGood.id.toString(),
|
||||
sdsGoodId: rel.originGood.sdsGoodId,
|
||||
source: rel.originGood.source,
|
||||
isCustom: rel.originGood.source === 'CUSTOM',
|
||||
goodName: rel.originGood.goodName,
|
||||
goodImage: rel.originGood.goodImage,
|
||||
goodPrice:
|
||||
rel.originGood.goodPrice === null ||
|
||||
rel.originGood.goodPrice === undefined
|
||||
? null
|
||||
goodName: rel.originGood.goodName,
|
||||
goodImage: rel.originGood.goodImage,
|
||||
goodPrice:
|
||||
rel.originGood.goodPrice === null ||
|
||||
rel.originGood.goodPrice === undefined
|
||||
? null
|
||||
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
||||
hasDetail: Boolean(rel.originGood.detail),
|
||||
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
|
||||
@@ -173,7 +173,7 @@ export class GoodDto {
|
||||
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
||||
productCode: rel.originGood.detail?.productCode ?? null,
|
||||
}
|
||||
: null,
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,10 +207,10 @@ export class GoodDetailDto extends GoodDto {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaginatedGoods {
|
||||
items: GoodDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
|
||||
export interface PaginatedGoods {
|
||||
items: GoodDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class QueryGoodDto {
|
||||
@ApiProperty({ required: false, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiProperty({ required: false, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 20;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Includes all descendants of this category recursively',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
tagId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class QueryGoodDto {
|
||||
@ApiProperty({ required: false, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiProperty({ required: false, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 20;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Includes all descendants of this category recursively',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
tagId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateGoodDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodName?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
goodPriority?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodImage?: string | null;
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateGoodDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodName?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
goodPriority?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodImage?: string | null;
|
||||
}
|
||||
@@ -1,59 +1,59 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { CreateGoodDto } from './dto/create-good.dto';
|
||||
import { UpdateGoodDto } from './dto/update-good.dto';
|
||||
import { QueryGoodDto } from './dto/query-good.dto';
|
||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { CreateGoodDto } from './dto/create-good.dto';
|
||||
import { UpdateGoodDto } from './dto/update-good.dto';
|
||||
import { QueryGoodDto } from './dto/query-good.dto';
|
||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||
import {
|
||||
CreateCustomGoodDto,
|
||||
UpdateCustomGoodContentDto,
|
||||
} from './dto/custom-good.dto';
|
||||
|
||||
@ApiTags('goods')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('goods')
|
||||
export class GoodsController {
|
||||
constructor(private readonly service: GoodsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List goods with filters & pagination' })
|
||||
findAll(@Query() query: QueryGoodDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
@ApiTags('goods')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('goods')
|
||||
export class GoodsController {
|
||||
constructor(private readonly service: GoodsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List goods with filters & pagination' })
|
||||
findAll(@Query() query: QueryGoodDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a good' })
|
||||
create(@Body() dto: CreateGoodDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Create a good' })
|
||||
create(@Body() dto: CreateGoodDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch('batch-priority')
|
||||
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
|
||||
batchPriority(@Body() dto: BatchPriorityDto) {
|
||||
return this.service.batchUpdatePriority(dto);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
|
||||
batchPriority(@Body() dto: BatchPriorityDto) {
|
||||
return this.service.batchUpdatePriority(dto);
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
|
||||
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
|
||||
batchCreate(@Body() dto: BatchCreateGoodDto) {
|
||||
return this.service.batchCreate(dto);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GoodsController } from './goods.controller';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GoodsController } from './goods.controller';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { SyncModule } from '../sync/sync.module';
|
||||
|
||||
@Module({
|
||||
imports: [SyncModule],
|
||||
controllers: [GoodsController],
|
||||
providers: [GoodsService],
|
||||
exports: [GoodsService],
|
||||
})
|
||||
export class GoodsModule {}
|
||||
controllers: [GoodsController],
|
||||
providers: [GoodsService],
|
||||
exports: [GoodsService],
|
||||
})
|
||||
export class GoodsModule {}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
describe('GoodsService', () => {
|
||||
let service: GoodsService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
|
||||
// Fixtures
|
||||
let countryId: bigint;
|
||||
let country2Id: bigint;
|
||||
let categoryId: bigint;
|
||||
let childCategoryId: bigint;
|
||||
let tagId: bigint;
|
||||
let positionId: bigint;
|
||||
let originGoodIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
|
||||
describe('GoodsService', () => {
|
||||
let service: GoodsService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
|
||||
// Fixtures
|
||||
let countryId: bigint;
|
||||
let country2Id: bigint;
|
||||
let categoryId: bigint;
|
||||
let childCategoryId: bigint;
|
||||
let tagId: bigint;
|
||||
let positionId: bigint;
|
||||
let originGoodIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
GoodsService,
|
||||
PrismaService,
|
||||
@@ -31,92 +31,92 @@ describe('GoodsService', () => {
|
||||
useValue: { queueProductDetailSync: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(GoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const country = await prisma.country.create({
|
||||
data: { countryName: `Goods Country ${stamp}` },
|
||||
});
|
||||
countryId = country.id;
|
||||
const country2 = await prisma.country.create({
|
||||
data: { countryName: `Goods Country 2 ${stamp}` },
|
||||
});
|
||||
country2Id = country2.id;
|
||||
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Goods Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
const child = await prisma.category.create({
|
||||
data: { categoryName: `Goods Child ${stamp}`, parentCategoryId: cat.id },
|
||||
});
|
||||
childCategoryId = child.id;
|
||||
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName: `Goods Tag ${stamp}`, tagColor: '#00FF00' },
|
||||
});
|
||||
tagId = tag.id;
|
||||
|
||||
const pos = await prisma.position.create({
|
||||
data: { indexVal: 1, countryId, categoryId },
|
||||
});
|
||||
positionId = pos.id;
|
||||
|
||||
const originGoods = await Promise.all(
|
||||
Array.from({ length: 5 }).map((_, i) =>
|
||||
prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `sds-goods-${stamp}-${i}`,
|
||||
goodName: `Goods Origin ${stamp} ${i}`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
originGoodIds = originGoods.map((og) => og.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Wipe all goods first so origin_goods/category can be removed.
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Goods Test ${stamp}` } },
|
||||
});
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Origin ${stamp}` } },
|
||||
});
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { id: { in: originGoodIds } },
|
||||
});
|
||||
await prisma.position.delete({ where: { id: positionId } });
|
||||
await prisma.tag.delete({ where: { id: tagId } });
|
||||
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.country.delete({ where: { id: country2Id } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
}).compile();
|
||||
service = moduleRef.get(GoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const country = await prisma.country.create({
|
||||
data: { countryName: `Goods Country ${stamp}` },
|
||||
});
|
||||
countryId = country.id;
|
||||
const country2 = await prisma.country.create({
|
||||
data: { countryName: `Goods Country 2 ${stamp}` },
|
||||
});
|
||||
country2Id = country2.id;
|
||||
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Goods Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
const child = await prisma.category.create({
|
||||
data: { categoryName: `Goods Child ${stamp}`, parentCategoryId: cat.id },
|
||||
});
|
||||
childCategoryId = child.id;
|
||||
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName: `Goods Tag ${stamp}`, tagColor: '#00FF00' },
|
||||
});
|
||||
tagId = tag.id;
|
||||
|
||||
const pos = await prisma.position.create({
|
||||
data: { indexVal: 1, countryId, categoryId },
|
||||
});
|
||||
positionId = pos.id;
|
||||
|
||||
const originGoods = await Promise.all(
|
||||
Array.from({ length: 5 }).map((_, i) =>
|
||||
prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `sds-goods-${stamp}-${i}`,
|
||||
goodName: `Goods Origin ${stamp} ${i}`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
originGoodIds = originGoods.map((og) => og.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Wipe all goods first so origin_goods/category can be removed.
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Goods Test ${stamp}` } },
|
||||
});
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Origin ${stamp}` } },
|
||||
});
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { id: { in: originGoodIds } },
|
||||
});
|
||||
await prisma.position.delete({ where: { id: positionId } });
|
||||
await prisma.tag.delete({ where: { id: tagId } });
|
||||
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.country.delete({ where: { id: country2Id } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates and reads back a good', async () => {
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} basic`,
|
||||
originGoodId: Number(originGoodIds[0]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
tagIds: [Number(tagId)],
|
||||
positionId: Number(positionId),
|
||||
goodPriority: 3,
|
||||
});
|
||||
expect(created.id).toBeTruthy();
|
||||
expect(created.country?.countryName).toBeTruthy();
|
||||
expect(created.tags.some((t) => t.tagColor === '#00FF00')).toBe(true);
|
||||
|
||||
const fetched = await service.findOne(BigInt(created.id));
|
||||
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} basic`,
|
||||
originGoodId: Number(originGoodIds[0]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
tagIds: [Number(tagId)],
|
||||
positionId: Number(positionId),
|
||||
goodPriority: 3,
|
||||
});
|
||||
expect(created.id).toBeTruthy();
|
||||
expect(created.country?.countryName).toBeTruthy();
|
||||
expect(created.tags.some((t) => t.tagColor === '#00FF00')).toBe(true);
|
||||
|
||||
const fetched = await service.findOne(BigInt(created.id));
|
||||
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
||||
});
|
||||
|
||||
it('creates, edits, and removes a fully editable custom good', async () => {
|
||||
@@ -163,107 +163,107 @@ describe('GoodsService', () => {
|
||||
prisma.originGood.findUnique({ where: { id: customOriginId } }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('filters by countryId, tagId, positionId and keyword', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
countryId: Number(countryId),
|
||||
tagId: Number(tagId),
|
||||
keyword: `Goods Test ${stamp}`,
|
||||
});
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true);
|
||||
expect(
|
||||
result.items.every((g) => g.tags.some((t) => t.id === tagId.toString())),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('categoryId filter includes descendants recursively', async () => {
|
||||
const inChild = await service.create({
|
||||
goodName: `Goods Test ${stamp} child`,
|
||||
originGoodId: Number(originGoodIds[1]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(childCategoryId),
|
||||
});
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
categoryId: Number(categoryId),
|
||||
keyword: `Goods Test ${stamp}`,
|
||||
});
|
||||
const ids = result.items.map((g) => g.id);
|
||||
expect(ids).toContain(inChild.id);
|
||||
});
|
||||
|
||||
it('batch update priority is atomic', async () => {
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} prio`,
|
||||
originGoodId: Number(originGoodIds[2]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
const result = await service.batchUpdatePriority({
|
||||
items: [{ id: Number(created.id), priority: 42 }],
|
||||
});
|
||||
expect(result.count).toBe(1);
|
||||
const after = await service.findOne(BigInt(created.id));
|
||||
expect(after.goodPriority).toBe(42);
|
||||
});
|
||||
|
||||
it('batch create creates all rows or none', async () => {
|
||||
// Count of goods whose originGoodId is one of the two fixture ids,
|
||||
// so we are independent of goodName (which batch derives from origin).
|
||||
const before = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
const created = await service.batchCreate({
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
defaultPriority: 1,
|
||||
items: [
|
||||
{ originGoodId: Number(originGoodIds[3]) },
|
||||
{ originGoodId: Number(originGoodIds[4]) },
|
||||
],
|
||||
});
|
||||
expect(created.length).toBe(2);
|
||||
const after = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
expect(after.total).toBe(before.total + 2);
|
||||
});
|
||||
|
||||
it('batch create rolls back on failure', async () => {
|
||||
const before = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
await expect(
|
||||
service.batchCreate({
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
items: [
|
||||
{ originGoodId: Number(originGoodIds[0]) },
|
||||
{ originGoodId: 99999999 }, // missing -> failure
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const after = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
expect(after.total).toBe(before.total);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by countryId, tagId, positionId and keyword', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
countryId: Number(countryId),
|
||||
tagId: Number(tagId),
|
||||
keyword: `Goods Test ${stamp}`,
|
||||
});
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true);
|
||||
expect(
|
||||
result.items.every((g) => g.tags.some((t) => t.id === tagId.toString())),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('categoryId filter includes descendants recursively', async () => {
|
||||
const inChild = await service.create({
|
||||
goodName: `Goods Test ${stamp} child`,
|
||||
originGoodId: Number(originGoodIds[1]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(childCategoryId),
|
||||
});
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
categoryId: Number(categoryId),
|
||||
keyword: `Goods Test ${stamp}`,
|
||||
});
|
||||
const ids = result.items.map((g) => g.id);
|
||||
expect(ids).toContain(inChild.id);
|
||||
});
|
||||
|
||||
it('batch update priority is atomic', async () => {
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} prio`,
|
||||
originGoodId: Number(originGoodIds[2]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
const result = await service.batchUpdatePriority({
|
||||
items: [{ id: Number(created.id), priority: 42 }],
|
||||
});
|
||||
expect(result.count).toBe(1);
|
||||
const after = await service.findOne(BigInt(created.id));
|
||||
expect(after.goodPriority).toBe(42);
|
||||
});
|
||||
|
||||
it('batch create creates all rows or none', async () => {
|
||||
// Count of goods whose originGoodId is one of the two fixture ids,
|
||||
// so we are independent of goodName (which batch derives from origin).
|
||||
const before = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
const created = await service.batchCreate({
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
defaultPriority: 1,
|
||||
items: [
|
||||
{ originGoodId: Number(originGoodIds[3]) },
|
||||
{ originGoodId: Number(originGoodIds[4]) },
|
||||
],
|
||||
});
|
||||
expect(created.length).toBe(2);
|
||||
const after = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
expect(after.total).toBe(before.total + 2);
|
||||
});
|
||||
|
||||
it('batch create rolls back on failure', async () => {
|
||||
const before = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
await expect(
|
||||
service.batchCreate({
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
items: [
|
||||
{ originGoodId: Number(originGoodIds[0]) },
|
||||
{ originGoodId: 99999999 }, // missing -> failure
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const after = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
expect(after.total).toBe(before.total);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+286
-286
@@ -1,14 +1,14 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateGoodDto } from './dto/create-good.dto';
|
||||
import { UpdateGoodDto } from './dto/update-good.dto';
|
||||
import { QueryGoodDto } from './dto/query-good.dto';
|
||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateGoodDto } from './dto/create-good.dto';
|
||||
import { UpdateGoodDto } from './dto/update-good.dto';
|
||||
import { QueryGoodDto } from './dto/query-good.dto';
|
||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
CustomGoodVariantDto,
|
||||
UpdateCustomGoodContentDto,
|
||||
} from './dto/custom-good.dto';
|
||||
|
||||
const GOOD_INCLUDE = {
|
||||
country: true,
|
||||
category: true,
|
||||
tag: true,
|
||||
position: true,
|
||||
|
||||
const GOOD_INCLUDE = {
|
||||
country: true,
|
||||
category: true,
|
||||
tag: true,
|
||||
position: true,
|
||||
originGood: {
|
||||
include: {
|
||||
detail: true,
|
||||
@@ -32,105 +32,105 @@ const GOOD_INCLUDE = {
|
||||
_count: { select: { variants: true } },
|
||||
},
|
||||
},
|
||||
goodTags: { include: { tag: true } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
@Injectable()
|
||||
export class GoodsService {
|
||||
goodTags: { include: { tag: true } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
@Injectable()
|
||||
export class GoodsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly syncService: SyncService,
|
||||
) {}
|
||||
|
||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
||||
const where: Prisma.GoodWhereInput = {};
|
||||
if (countryId !== undefined) where.countryId = BigInt(countryId);
|
||||
if (tagId !== undefined) where.goodTags = { some: { tagId: BigInt(tagId) } };
|
||||
if (positionId !== undefined) where.positionId = BigInt(positionId);
|
||||
if (keyword) {
|
||||
where.goodName = { contains: keyword, mode: 'insensitive' };
|
||||
}
|
||||
if (categoryId !== undefined) {
|
||||
const ids = await this.collectCategoryDescendants(BigInt(categoryId));
|
||||
where.categoryId = { in: ids };
|
||||
}
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.good.count({ where }),
|
||||
this.prisma.good.findMany({
|
||||
where,
|
||||
include: GOOD_INCLUDE,
|
||||
orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((g) => GoodDto.from(g, {
|
||||
country: g.country,
|
||||
category: g.category,
|
||||
tag: g.tag,
|
||||
position: g.position,
|
||||
originGood: g.originGood,
|
||||
goodTags: g.goodTags,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
||||
const where: Prisma.GoodWhereInput = {};
|
||||
if (countryId !== undefined) where.countryId = BigInt(countryId);
|
||||
if (tagId !== undefined) where.goodTags = { some: { tagId: BigInt(tagId) } };
|
||||
if (positionId !== undefined) where.positionId = BigInt(positionId);
|
||||
if (keyword) {
|
||||
where.goodName = { contains: keyword, mode: 'insensitive' };
|
||||
}
|
||||
if (categoryId !== undefined) {
|
||||
const ids = await this.collectCategoryDescendants(BigInt(categoryId));
|
||||
where.categoryId = { in: ids };
|
||||
}
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.good.count({ where }),
|
||||
this.prisma.good.findMany({
|
||||
where,
|
||||
include: GOOD_INCLUDE,
|
||||
orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((g) => GoodDto.from(g, {
|
||||
country: g.country,
|
||||
category: g.category,
|
||||
tag: g.tag,
|
||||
position: g.position,
|
||||
originGood: g.originGood,
|
||||
goodTags: g.goodTags,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: bigint): Promise<GoodDetailDto> {
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
return GoodDetailDto.fromGood(good, {
|
||||
country: good.country,
|
||||
category: good.category,
|
||||
tag: good.tag,
|
||||
position: good.position,
|
||||
originGood: good.originGood,
|
||||
goodTags: good.goodTags,
|
||||
});
|
||||
}
|
||||
|
||||
country: good.country,
|
||||
category: good.category,
|
||||
tag: good.tag,
|
||||
position: good.position,
|
||||
originGood: good.originGood,
|
||||
goodTags: good.goodTags,
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||
await this.ensureReferences(dto);
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.good.create({
|
||||
data: {
|
||||
goodName: dto.goodName,
|
||||
goodImage: dto.goodImage,
|
||||
originGoodId: BigInt(dto.originGoodId),
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodPriority: dto.goodPriority ?? 0,
|
||||
},
|
||||
});
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: created.id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
const created = await tx.good.create({
|
||||
data: {
|
||||
goodName: dto.goodName,
|
||||
goodImage: dto.goodImage,
|
||||
originGoodId: BigInt(dto.originGoodId),
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodPriority: dto.goodPriority ?? 0,
|
||||
},
|
||||
});
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: created.id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
return GoodDto.from(result, {
|
||||
country: result.country,
|
||||
category: result.category,
|
||||
tag: result.tag,
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
country: result.country,
|
||||
category: result.category,
|
||||
tag: result.tag,
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
});
|
||||
});
|
||||
if (
|
||||
@@ -243,61 +243,61 @@ export class GoodsService {
|
||||
});
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.GoodUpdateInput = {};
|
||||
if (dto.goodName !== undefined) data.goodName = dto.goodName;
|
||||
if (dto.originGoodId !== undefined) {
|
||||
await this.ensureOriginGood(dto.originGoodId);
|
||||
data.originGood = { connect: { id: BigInt(dto.originGoodId) } };
|
||||
}
|
||||
if (dto.countryId !== undefined) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
data.country = { connect: { id: BigInt(dto.countryId) } };
|
||||
}
|
||||
if (dto.categoryId !== undefined) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
data.category = { connect: { id: BigInt(dto.categoryId) } };
|
||||
}
|
||||
if (dto.positionId !== undefined) {
|
||||
data.position =
|
||||
dto.positionId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.positionId) } };
|
||||
}
|
||||
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
|
||||
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
|
||||
if (dto.tagIds !== undefined) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.GoodUpdateInput = {};
|
||||
if (dto.goodName !== undefined) data.goodName = dto.goodName;
|
||||
if (dto.originGoodId !== undefined) {
|
||||
await this.ensureOriginGood(dto.originGoodId);
|
||||
data.originGood = { connect: { id: BigInt(dto.originGoodId) } };
|
||||
}
|
||||
if (dto.countryId !== undefined) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
data.country = { connect: { id: BigInt(dto.countryId) } };
|
||||
}
|
||||
if (dto.categoryId !== undefined) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
data.category = { connect: { id: BigInt(dto.categoryId) } };
|
||||
}
|
||||
if (dto.positionId !== undefined) {
|
||||
data.position =
|
||||
dto.positionId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.positionId) } };
|
||||
}
|
||||
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
|
||||
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
|
||||
if (dto.tagIds !== undefined) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
if (dto.tagIds !== undefined) {
|
||||
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
||||
if (dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
const updated = await tx.good.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
if (dto.tagIds !== undefined) {
|
||||
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
||||
if (dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
const updated = await tx.good.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
return GoodDto.from(updated, {
|
||||
country: updated.country,
|
||||
category: updated.category,
|
||||
tag: updated.tag,
|
||||
position: updated.position,
|
||||
originGood: updated.originGood,
|
||||
goodTags: updated.goodTags,
|
||||
country: updated.country,
|
||||
category: updated.category,
|
||||
tag: updated.tag,
|
||||
position: updated.position,
|
||||
originGood: updated.originGood,
|
||||
goodTags: updated.goodTags,
|
||||
});
|
||||
});
|
||||
if (
|
||||
@@ -308,8 +308,8 @@ export class GoodsService {
|
||||
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async remove(id: bigint): Promise<{ id: string }> {
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
@@ -327,80 +327,80 @@ export class GoodsService {
|
||||
}
|
||||
}
|
||||
});
|
||||
return { id: id.toString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates priorities in a single transaction; either all rows update
|
||||
* or none do.
|
||||
*/
|
||||
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
|
||||
return { id: id.toString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates priorities in a single transaction; either all rows update
|
||||
* or none do.
|
||||
*/
|
||||
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of dto.items) {
|
||||
await tx.good.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: { goodPriority: item.priority },
|
||||
});
|
||||
}
|
||||
for (const item of dto.items) {
|
||||
await tx.good.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: { goodPriority: item.priority },
|
||||
});
|
||||
}
|
||||
return { count: dto.items.length };
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates multiple goods atomically, sharing countryId/categoryId/tagIds/positionId
|
||||
* and a default priority that may be overridden per item.
|
||||
*/
|
||||
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
|
||||
const defaultPriority = dto.defaultPriority ?? 0;
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates multiple goods atomically, sharing countryId/categoryId/tagIds/positionId
|
||||
* and a default priority that may be overridden per item.
|
||||
*/
|
||||
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
|
||||
const defaultPriority = dto.defaultPriority ?? 0;
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const created: GoodDto[] = [];
|
||||
for (const item of dto.items) {
|
||||
const og = await tx.originGood.findUnique({
|
||||
where: { id: BigInt(item.originGoodId) },
|
||||
});
|
||||
if (!og) {
|
||||
throw new BadRequestException(
|
||||
`Origin good ${item.originGoodId} not found`,
|
||||
);
|
||||
}
|
||||
const row = await tx.good.create({
|
||||
data: {
|
||||
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
originGoodId: og.id,
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodPriority: item.priority ?? defaultPriority,
|
||||
},
|
||||
});
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: row.id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
created.push(GoodDto.from(result, {
|
||||
country: result.country,
|
||||
category: result.category,
|
||||
tag: result.tag,
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
}));
|
||||
}
|
||||
const created: GoodDto[] = [];
|
||||
for (const item of dto.items) {
|
||||
const og = await tx.originGood.findUnique({
|
||||
where: { id: BigInt(item.originGoodId) },
|
||||
});
|
||||
if (!og) {
|
||||
throw new BadRequestException(
|
||||
`Origin good ${item.originGoodId} not found`,
|
||||
);
|
||||
}
|
||||
const row = await tx.good.create({
|
||||
data: {
|
||||
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
originGoodId: og.id,
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodPriority: item.priority ?? defaultPriority,
|
||||
},
|
||||
});
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: row.id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
created.push(GoodDto.from(result, {
|
||||
country: result.country,
|
||||
category: result.category,
|
||||
tag: result.tag,
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
}));
|
||||
}
|
||||
return created;
|
||||
});
|
||||
for (const goodId of new Set(
|
||||
@@ -416,64 +416,64 @@ export class GoodsService {
|
||||
this.syncService.queueProductDetailSync(goodId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the category tree and return the requested id + all of its
|
||||
* descendants. We use a level-by-level BFS to keep the queries small
|
||||
* for the typical tree sizes we expect.
|
||||
*/
|
||||
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
|
||||
const ids: bigint[] = [rootId];
|
||||
let frontier: bigint[] = [rootId];
|
||||
while (frontier.length > 0) {
|
||||
const children = await this.prisma.category.findMany({
|
||||
where: { parentCategoryId: { in: frontier } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (children.length === 0) break;
|
||||
const childIds = children.map((c) => c.id);
|
||||
ids.push(...childIds);
|
||||
frontier = childIds;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async ensureOriginGood(id: number) {
|
||||
const og = await this.prisma.originGood.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
});
|
||||
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCountry(id: number) {
|
||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCategory(id: number) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the category tree and return the requested id + all of its
|
||||
* descendants. We use a level-by-level BFS to keep the queries small
|
||||
* for the typical tree sizes we expect.
|
||||
*/
|
||||
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
|
||||
const ids: bigint[] = [rootId];
|
||||
let frontier: bigint[] = [rootId];
|
||||
while (frontier.length > 0) {
|
||||
const children = await this.prisma.category.findMany({
|
||||
where: { parentCategoryId: { in: frontier } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (children.length === 0) break;
|
||||
const childIds = children.map((c) => c.id);
|
||||
ids.push(...childIds);
|
||||
frontier = childIds;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async ensureOriginGood(id: number) {
|
||||
const og = await this.prisma.originGood.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
});
|
||||
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCountry(id: number) {
|
||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCategory(id: number) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureTag(id: number) {
|
||||
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!t) throw new BadRequestException(`Tag ${id} not found`);
|
||||
}
|
||||
|
||||
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!t) throw new BadRequestException(`Tag ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureReferences(dto: CreateGoodDto) {
|
||||
await this.ensureOriginGood(dto.originGoodId);
|
||||
await this.ensureCountry(dto.countryId);
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
if (dto.positionId !== undefined) {
|
||||
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
|
||||
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||
}
|
||||
await this.ensureOriginGood(dto.originGoodId);
|
||||
await this.ensureCountry(dto.countryId);
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
if (dto.positionId !== undefined) {
|
||||
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
|
||||
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePosition(id: number) {
|
||||
|
||||
@@ -31,7 +31,9 @@ async function bootstrap() {
|
||||
);
|
||||
|
||||
// Security headers (X-Content-Type-Options, X-Frame-Options, CSP, HSTS, ...)
|
||||
app.use(helmet());
|
||||
// Static assets (/uploads, /assets) are embedded cross-origin by other
|
||||
// sites, so CORP must allow cross-origin reads.
|
||||
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
|
||||
|
||||
// Parse auth cookies (HttpOnly access/refresh tokens). Resolve both the
|
||||
// namespace and its `default` interop shape so it works regardless of
|
||||
@@ -45,12 +47,14 @@ async function bootstrap() {
|
||||
|
||||
// CORS: only origins listed in CORS_ORIGINS (comma-separated) are
|
||||
// allowed. Credentials are enabled because the session lives in
|
||||
// HttpOnly cookies.
|
||||
// HttpOnly cookies. "*" disables the allowlist and reflects any origin
|
||||
// (reflected origins are required when credentials are enabled).
|
||||
const corsOrigins = (process.env.CORS_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
app.enableCors(corsOrigins.length > 0 ? { origin: corsOrigins, credentials: true } : undefined);
|
||||
const origin = corsOrigins.includes('*') ? true : corsOrigins;
|
||||
app.enableCors(corsOrigins.length > 0 ? { origin, credentials: true } : undefined);
|
||||
|
||||
// Serve uploaded files. nosniff prevents browsers from sniffing a
|
||||
// non-image content type out of an uploaded file.
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class QueryOriginGoodDto {
|
||||
@ApiProperty({ required: false, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiProperty({ required: false, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 20;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Search by goodName' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class QueryOriginGoodDto {
|
||||
@ApiProperty({ required: false, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiProperty({ required: false, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 20;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Search by goodName' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||
|
||||
@ApiTags('origin-goods')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('origin-goods')
|
||||
export class OriginGoodsController {
|
||||
constructor(private readonly service: OriginGoodsService) {}
|
||||
|
||||
@Get('tree')
|
||||
@ApiOperation({
|
||||
summary: 'Origin goods grouped by SDS category with config status',
|
||||
})
|
||||
getTree() {
|
||||
return this.service.getTree();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
|
||||
findAll(@Query() query: QueryOriginGoodDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
}
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||
|
||||
@ApiTags('origin-goods')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('origin-goods')
|
||||
export class OriginGoodsController {
|
||||
constructor(private readonly service: OriginGoodsService) {}
|
||||
|
||||
@Get('tree')
|
||||
@ApiOperation({
|
||||
summary: 'Origin goods grouped by SDS category with config status',
|
||||
})
|
||||
getTree() {
|
||||
return this.service.getTree();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
|
||||
findAll(@Query() query: QueryOriginGoodDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OriginGoodsController } from './origin-goods.controller';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OriginGoodsController],
|
||||
providers: [OriginGoodsService],
|
||||
})
|
||||
export class OriginGoodsModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OriginGoodsController } from './origin-goods.controller';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OriginGoodsController],
|
||||
providers: [OriginGoodsService],
|
||||
})
|
||||
export class OriginGoodsModule {}
|
||||
|
||||
@@ -1,80 +1,80 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('OriginGoodsService', () => {
|
||||
let service: OriginGoodsService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
const createdSds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [OriginGoodsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(OriginGoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
// Seed 25 rows with sequential goodNames so we can paginate/filter.
|
||||
const rows = Array.from({ length: 25 }).map((_, i) => ({
|
||||
sdsGoodId: `sds-${stamp}-${i}`,
|
||||
goodName: `Origin Good ${stamp} ${i.toString().padStart(2, '0')}`,
|
||||
sdsCategoryId: `cat-${stamp}-${i % 3}`,
|
||||
}));
|
||||
await prisma.originGood.createMany({ data: rows });
|
||||
createdSds.push(...rows.map((r) => r.sdsGoodId));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdSds.length) {
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { sdsGoodId: { in: createdSds } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns paginated results', async () => {
|
||||
const page1 = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: `Origin Good ${stamp}`,
|
||||
});
|
||||
expect(page1.total).toBe(25);
|
||||
expect(page1.items.length).toBe(10);
|
||||
expect(page1.page).toBe(1);
|
||||
expect(page1.pageSize).toBe(10);
|
||||
|
||||
const page3 = await service.findAll({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
keyword: `Origin Good ${stamp}`,
|
||||
});
|
||||
expect(page3.items.length).toBe(5);
|
||||
});
|
||||
|
||||
it('searches by keyword (case insensitive)', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
keyword: `origin good ${stamp} 05`,
|
||||
});
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].goodName).toContain('05');
|
||||
});
|
||||
|
||||
it('returns empty page when no matches', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
keyword: 'definitely-does-not-exist',
|
||||
});
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('OriginGoodsService', () => {
|
||||
let service: OriginGoodsService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
const createdSds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [OriginGoodsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(OriginGoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
// Seed 25 rows with sequential goodNames so we can paginate/filter.
|
||||
const rows = Array.from({ length: 25 }).map((_, i) => ({
|
||||
sdsGoodId: `sds-${stamp}-${i}`,
|
||||
goodName: `Origin Good ${stamp} ${i.toString().padStart(2, '0')}`,
|
||||
sdsCategoryId: `cat-${stamp}-${i % 3}`,
|
||||
}));
|
||||
await prisma.originGood.createMany({ data: rows });
|
||||
createdSds.push(...rows.map((r) => r.sdsGoodId));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdSds.length) {
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { sdsGoodId: { in: createdSds } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns paginated results', async () => {
|
||||
const page1 = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: `Origin Good ${stamp}`,
|
||||
});
|
||||
expect(page1.total).toBe(25);
|
||||
expect(page1.items.length).toBe(10);
|
||||
expect(page1.page).toBe(1);
|
||||
expect(page1.pageSize).toBe(10);
|
||||
|
||||
const page3 = await service.findAll({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
keyword: `Origin Good ${stamp}`,
|
||||
});
|
||||
expect(page3.items.length).toBe(5);
|
||||
});
|
||||
|
||||
it('searches by keyword (case insensitive)', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
keyword: `origin good ${stamp} 05`,
|
||||
});
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].goodName).toContain('05');
|
||||
});
|
||||
|
||||
it('returns empty page when no matches', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
keyword: 'definitely-does-not-exist',
|
||||
});
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,306 +1,306 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||
|
||||
export interface PaginatedOriginGoods {
|
||||
items: Array<{
|
||||
id: string;
|
||||
sdsGoodId: string;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
sdsCategoryId: string | null;
|
||||
createdAt: string;
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||
|
||||
export interface PaginatedOriginGoods {
|
||||
items: Array<{
|
||||
id: string;
|
||||
sdsGoodId: string;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
sdsCategoryId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
hasDetail: boolean;
|
||||
detailSyncedAt: string | null;
|
||||
variantCount: number;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single origin-good node inside the tree, augmented with configuration status
|
||||
* (how many `goods` rows reference it and which countries it has been configured for).
|
||||
*/
|
||||
export interface OriginGoodsTreeNode {
|
||||
id: string;
|
||||
goodName: string;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
sdsGoodId: string;
|
||||
delisted: boolean;
|
||||
configuredCount: number;
|
||||
configuredCountries: string[];
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single origin-good node inside the tree, augmented with configuration status
|
||||
* (how many `goods` rows reference it and which countries it has been configured for).
|
||||
*/
|
||||
export interface OriginGoodsTreeNode {
|
||||
id: string;
|
||||
goodName: string;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
sdsGoodId: string;
|
||||
delisted: boolean;
|
||||
configuredCount: number;
|
||||
configuredCountries: string[];
|
||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
|
||||
hasDetail: boolean;
|
||||
detailSyncedAt: string | null;
|
||||
variantCount: number;
|
||||
sizeRowCount: number;
|
||||
packageRowCount: number;
|
||||
}
|
||||
|
||||
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
||||
export interface OriginGoodsTreeCategoryNode {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
sdsCategoryId: string | null;
|
||||
configuredCount: number;
|
||||
totalCount: number;
|
||||
children: OriginGoodsTreeCategoryNode[];
|
||||
originGoods: OriginGoodsTreeNode[];
|
||||
}
|
||||
|
||||
/** Top-level tree response returned by `OriginGoodsService.getTree()`. */
|
||||
export interface OriginGoodsTreeResponse {
|
||||
tree: OriginGoodsTreeCategoryNode[];
|
||||
totalOriginGoods: number;
|
||||
configuredCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OriginGoodsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
||||
const { page, pageSize, keyword } = query;
|
||||
}
|
||||
|
||||
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
||||
export interface OriginGoodsTreeCategoryNode {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
sdsCategoryId: string | null;
|
||||
configuredCount: number;
|
||||
totalCount: number;
|
||||
children: OriginGoodsTreeCategoryNode[];
|
||||
originGoods: OriginGoodsTreeNode[];
|
||||
}
|
||||
|
||||
/** Top-level tree response returned by `OriginGoodsService.getTree()`. */
|
||||
export interface OriginGoodsTreeResponse {
|
||||
tree: OriginGoodsTreeCategoryNode[];
|
||||
totalOriginGoods: number;
|
||||
configuredCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OriginGoodsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
||||
const { page, pageSize, keyword } = query;
|
||||
const where: Prisma.OriginGoodWhereInput = {
|
||||
source: 'SDS',
|
||||
...(keyword
|
||||
? { goodName: { contains: keyword, mode: 'insensitive' as const } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.originGood.count({ where }),
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.originGood.count({ where }),
|
||||
this.prisma.originGood.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
include: { detail: true, _count: { select: { variants: true } } },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
sdsGoodId: r.sdsGoodId,
|
||||
goodName: r.goodName,
|
||||
goodImage: r.goodImage,
|
||||
goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(),
|
||||
sdsCategoryId: r.sdsCategoryId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
sdsGoodId: r.sdsGoodId,
|
||||
goodName: r.goodName,
|
||||
goodImage: r.goodImage,
|
||||
goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(),
|
||||
sdsCategoryId: r.sdsCategoryId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
hasDetail: Boolean(r.detail),
|
||||
detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null,
|
||||
variantCount: r._count.variants,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a hierarchical tree using the `categories` table parent-child
|
||||
* structure, placing each origin-good as a leaf under the category whose
|
||||
* `sdsCategoryId` matches the origin-good's `sdsCategoryId`.
|
||||
*
|
||||
* Origin-goods whose `sdsCategoryId` doesn't map to any category are placed
|
||||
* under a synthetic "未分类" root node.
|
||||
*/
|
||||
async getTree(): Promise<OriginGoodsTreeResponse> {
|
||||
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] =
|
||||
await Promise.all([
|
||||
this.prisma.category.findMany({
|
||||
where: { sdsCategoryId: { not: null } },
|
||||
orderBy: { categoryName: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
categoryName: true,
|
||||
sdsCategoryId: true,
|
||||
parentCategoryId: true,
|
||||
},
|
||||
}),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a hierarchical tree using the `categories` table parent-child
|
||||
* structure, placing each origin-good as a leaf under the category whose
|
||||
* `sdsCategoryId` matches the origin-good's `sdsCategoryId`.
|
||||
*
|
||||
* Origin-goods whose `sdsCategoryId` doesn't map to any category are placed
|
||||
* under a synthetic "未分类" root node.
|
||||
*/
|
||||
async getTree(): Promise<OriginGoodsTreeResponse> {
|
||||
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] =
|
||||
await Promise.all([
|
||||
this.prisma.category.findMany({
|
||||
where: { sdsCategoryId: { not: null } },
|
||||
orderBy: { categoryName: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
categoryName: true,
|
||||
sdsCategoryId: true,
|
||||
parentCategoryId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.originGood.findMany({
|
||||
where: { delisted: false, source: 'SDS' },
|
||||
orderBy: { goodName: 'asc' },
|
||||
include: { detail: true, _count: { select: { variants: true } } },
|
||||
}),
|
||||
this.prisma.good.groupBy({
|
||||
by: ['originGoodId'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.good.findMany({
|
||||
select: {
|
||||
originGoodId: true,
|
||||
country: { select: { countryName: true } },
|
||||
},
|
||||
distinct: ['originGoodId', 'countryId'],
|
||||
}),
|
||||
this.prisma.goodTag.findMany({
|
||||
select: {
|
||||
good: { select: { originGoodId: true } },
|
||||
tag: {
|
||||
select: {
|
||||
tagName: true,
|
||||
tagColor: true,
|
||||
tagFontColor: true,
|
||||
tagGroupId: true,
|
||||
sortOrder: true,
|
||||
tagGroup: { select: { groupName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const countMap = new Map<string, number>();
|
||||
configCounts.forEach((c) =>
|
||||
countMap.set(c.originGoodId.toString(), c._count._all),
|
||||
);
|
||||
|
||||
const countryMap = new Map<string, string[]>();
|
||||
goodsWithCountries.forEach((g) => {
|
||||
const key = g.originGoodId.toString();
|
||||
const name = g.country?.countryName;
|
||||
if (!name) return;
|
||||
const arr = countryMap.get(key);
|
||||
if (arr) arr.push(name);
|
||||
else countryMap.set(key, [name]);
|
||||
});
|
||||
|
||||
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
|
||||
goodsWithTags.forEach((gt) => {
|
||||
const key = gt.good.originGoodId.toString();
|
||||
const tagInfo = {
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
tagGroupId: gt.tag.tagGroupId?.toString() ?? null,
|
||||
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
|
||||
sortOrder: gt.tag.sortOrder,
|
||||
};
|
||||
const arr = tagMap.get(key);
|
||||
if (arr) {
|
||||
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
||||
} else {
|
||||
tagMap.set(key, [tagInfo]);
|
||||
}
|
||||
});
|
||||
|
||||
const sdsToCategory = new Map<
|
||||
string,
|
||||
(typeof allCategories)[number]
|
||||
>();
|
||||
for (const c of allCategories) {
|
||||
if (c.sdsCategoryId) sdsToCategory.set(c.sdsCategoryId, c);
|
||||
}
|
||||
|
||||
const ogToCategory = new Map<string, string>();
|
||||
for (const og of allOriginGoods) {
|
||||
if (og.sdsCategoryId && sdsToCategory.has(og.sdsCategoryId)) {
|
||||
ogToCategory.set(og.id.toString(), sdsToCategory.get(og.sdsCategoryId)!.id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const buildNode = (
|
||||
cat: (typeof allCategories)[number],
|
||||
): OriginGoodsTreeCategoryNode => {
|
||||
const childrenCats = allCategories.filter(
|
||||
(c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id,
|
||||
);
|
||||
const childNodes = childrenCats
|
||||
.map(buildNode)
|
||||
.filter((n) => n.totalCount > 0);
|
||||
|
||||
const ogsForThisCat = allOriginGoods.filter(
|
||||
(og) => ogToCategory.get(og.id.toString()) === cat.id.toString(),
|
||||
);
|
||||
const ogNodes: OriginGoodsTreeNode[] = ogsForThisCat.map((og) => ({
|
||||
id: og.id.toString(),
|
||||
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
goodPrice: og.goodPrice?.toString() ?? null,
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
delisted: og.delisted,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
this.prisma.good.groupBy({
|
||||
by: ['originGoodId'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.good.findMany({
|
||||
select: {
|
||||
originGoodId: true,
|
||||
country: { select: { countryName: true } },
|
||||
},
|
||||
distinct: ['originGoodId', 'countryId'],
|
||||
}),
|
||||
this.prisma.goodTag.findMany({
|
||||
select: {
|
||||
good: { select: { originGoodId: true } },
|
||||
tag: {
|
||||
select: {
|
||||
tagName: true,
|
||||
tagColor: true,
|
||||
tagFontColor: true,
|
||||
tagGroupId: true,
|
||||
sortOrder: true,
|
||||
tagGroup: { select: { groupName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const countMap = new Map<string, number>();
|
||||
configCounts.forEach((c) =>
|
||||
countMap.set(c.originGoodId.toString(), c._count._all),
|
||||
);
|
||||
|
||||
const countryMap = new Map<string, string[]>();
|
||||
goodsWithCountries.forEach((g) => {
|
||||
const key = g.originGoodId.toString();
|
||||
const name = g.country?.countryName;
|
||||
if (!name) return;
|
||||
const arr = countryMap.get(key);
|
||||
if (arr) arr.push(name);
|
||||
else countryMap.set(key, [name]);
|
||||
});
|
||||
|
||||
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
|
||||
goodsWithTags.forEach((gt) => {
|
||||
const key = gt.good.originGoodId.toString();
|
||||
const tagInfo = {
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
tagGroupId: gt.tag.tagGroupId?.toString() ?? null,
|
||||
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
|
||||
sortOrder: gt.tag.sortOrder,
|
||||
};
|
||||
const arr = tagMap.get(key);
|
||||
if (arr) {
|
||||
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
||||
} else {
|
||||
tagMap.set(key, [tagInfo]);
|
||||
}
|
||||
});
|
||||
|
||||
const sdsToCategory = new Map<
|
||||
string,
|
||||
(typeof allCategories)[number]
|
||||
>();
|
||||
for (const c of allCategories) {
|
||||
if (c.sdsCategoryId) sdsToCategory.set(c.sdsCategoryId, c);
|
||||
}
|
||||
|
||||
const ogToCategory = new Map<string, string>();
|
||||
for (const og of allOriginGoods) {
|
||||
if (og.sdsCategoryId && sdsToCategory.has(og.sdsCategoryId)) {
|
||||
ogToCategory.set(og.id.toString(), sdsToCategory.get(og.sdsCategoryId)!.id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const buildNode = (
|
||||
cat: (typeof allCategories)[number],
|
||||
): OriginGoodsTreeCategoryNode => {
|
||||
const childrenCats = allCategories.filter(
|
||||
(c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id,
|
||||
);
|
||||
const childNodes = childrenCats
|
||||
.map(buildNode)
|
||||
.filter((n) => n.totalCount > 0);
|
||||
|
||||
const ogsForThisCat = allOriginGoods.filter(
|
||||
(og) => ogToCategory.get(og.id.toString()) === cat.id.toString(),
|
||||
);
|
||||
const ogNodes: OriginGoodsTreeNode[] = ogsForThisCat.map((og) => ({
|
||||
id: og.id.toString(),
|
||||
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
goodPrice: og.goodPrice?.toString() ?? null,
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
delisted: og.delisted,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
||||
hasDetail: Boolean(og.detail),
|
||||
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
|
||||
variantCount: og._count.variants,
|
||||
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
||||
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
|
||||
}));
|
||||
|
||||
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
|
||||
const childConfigured = childNodes.reduce(
|
||||
(s, n) => s + n.configuredCount,
|
||||
0,
|
||||
);
|
||||
const ogConfigured = ogNodes.filter((o) => o.configuredCount > 0).length;
|
||||
|
||||
return {
|
||||
categoryId: cat.id.toString(),
|
||||
categoryName: cat.categoryName,
|
||||
sdsCategoryId: cat.sdsCategoryId,
|
||||
configuredCount: childConfigured + ogConfigured,
|
||||
totalCount: childTotal + ogNodes.length,
|
||||
children: childNodes,
|
||||
originGoods: ogNodes,
|
||||
};
|
||||
};
|
||||
|
||||
const roots = allCategories.filter((c) => c.parentCategoryId === null);
|
||||
const tree = roots.map(buildNode).filter((n) => n.totalCount > 0);
|
||||
|
||||
const unmapped = allOriginGoods.filter(
|
||||
(og) => !ogToCategory.has(og.id.toString()),
|
||||
);
|
||||
if (unmapped.length > 0) {
|
||||
tree.push({
|
||||
categoryId: 'uncategorized',
|
||||
categoryName: '未分类',
|
||||
sdsCategoryId: null,
|
||||
configuredCount: unmapped.filter(
|
||||
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
||||
).length,
|
||||
totalCount: unmapped.length,
|
||||
children: [],
|
||||
originGoods: unmapped.map((og) => ({
|
||||
id: og.id.toString(),
|
||||
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
goodPrice: og.goodPrice?.toString() ?? null,
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
delisted: og.delisted,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
}));
|
||||
|
||||
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
|
||||
const childConfigured = childNodes.reduce(
|
||||
(s, n) => s + n.configuredCount,
|
||||
0,
|
||||
);
|
||||
const ogConfigured = ogNodes.filter((o) => o.configuredCount > 0).length;
|
||||
|
||||
return {
|
||||
categoryId: cat.id.toString(),
|
||||
categoryName: cat.categoryName,
|
||||
sdsCategoryId: cat.sdsCategoryId,
|
||||
configuredCount: childConfigured + ogConfigured,
|
||||
totalCount: childTotal + ogNodes.length,
|
||||
children: childNodes,
|
||||
originGoods: ogNodes,
|
||||
};
|
||||
};
|
||||
|
||||
const roots = allCategories.filter((c) => c.parentCategoryId === null);
|
||||
const tree = roots.map(buildNode).filter((n) => n.totalCount > 0);
|
||||
|
||||
const unmapped = allOriginGoods.filter(
|
||||
(og) => !ogToCategory.has(og.id.toString()),
|
||||
);
|
||||
if (unmapped.length > 0) {
|
||||
tree.push({
|
||||
categoryId: 'uncategorized',
|
||||
categoryName: '未分类',
|
||||
sdsCategoryId: null,
|
||||
configuredCount: unmapped.filter(
|
||||
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
||||
).length,
|
||||
totalCount: unmapped.length,
|
||||
children: [],
|
||||
originGoods: unmapped.map((og) => ({
|
||||
id: og.id.toString(),
|
||||
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
goodPrice: og.goodPrice?.toString() ?? null,
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
delisted: og.delisted,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
||||
hasDetail: Boolean(og.detail),
|
||||
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
|
||||
variantCount: og._count.variants,
|
||||
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
||||
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
tree.sort((a, b) => a.categoryName.localeCompare(b.categoryName, 'zh'));
|
||||
|
||||
const totalConfigured = allOriginGoods.filter(
|
||||
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
||||
).length;
|
||||
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
tree.sort((a, b) => a.categoryName.localeCompare(b.categoryName, 'zh'));
|
||||
|
||||
const totalConfigured = allOriginGoods.filter(
|
||||
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
||||
).length;
|
||||
|
||||
return {
|
||||
tree,
|
||||
totalOriginGoods: allOriginGoods.length,
|
||||
configuredCount: totalConfigured,
|
||||
tree,
|
||||
totalOriginGoods: allOriginGoods.length,
|
||||
configuredCount: totalConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePositionDto {
|
||||
@ApiProperty({ description: 'Sort order / weight' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
indexVal!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePositionDto {
|
||||
@ApiProperty({ description: 'Sort order / weight' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
indexVal!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdatePositionDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
indexVal?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number | null;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdatePositionDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
indexVal?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number | null;
|
||||
}
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { PositionsService } from './positions.service';
|
||||
import { CreatePositionDto } from './dto/create-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
|
||||
@ApiTags('positions')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('positions')
|
||||
export class PositionsController {
|
||||
constructor(private readonly service: PositionsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List positions (optionally filtered)' })
|
||||
@ApiQuery({ name: 'countryId', required: false, type: Number })
|
||||
@ApiQuery({ name: 'categoryId', required: false, type: Number })
|
||||
findAll(
|
||||
@Query('countryId') countryId?: string,
|
||||
@Query('categoryId') categoryId?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
countryId: countryId ? BigInt(countryId) : undefined,
|
||||
categoryId: categoryId ? BigInt(categoryId) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one position' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a position' })
|
||||
create(@Body() dto: CreatePositionDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a position' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdatePositionDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a position' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { PositionsService } from './positions.service';
|
||||
import { CreatePositionDto } from './dto/create-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
|
||||
@ApiTags('positions')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('positions')
|
||||
export class PositionsController {
|
||||
constructor(private readonly service: PositionsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List positions (optionally filtered)' })
|
||||
@ApiQuery({ name: 'countryId', required: false, type: Number })
|
||||
@ApiQuery({ name: 'categoryId', required: false, type: Number })
|
||||
findAll(
|
||||
@Query('countryId') countryId?: string,
|
||||
@Query('categoryId') categoryId?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
countryId: countryId ? BigInt(countryId) : undefined,
|
||||
categoryId: categoryId ? BigInt(categoryId) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one position' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a position' })
|
||||
create(@Body() dto: CreatePositionDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a position' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdatePositionDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a position' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PositionsController } from './positions.controller';
|
||||
import { PositionsService } from './positions.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PositionsController],
|
||||
providers: [PositionsService],
|
||||
exports: [PositionsService],
|
||||
})
|
||||
export class PositionsModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PositionsController } from './positions.controller';
|
||||
import { PositionsService } from './positions.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PositionsController],
|
||||
providers: [PositionsService],
|
||||
exports: [PositionsService],
|
||||
})
|
||||
export class PositionsModule {}
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { PositionsService } from './positions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('PositionsService', () => {
|
||||
let service: PositionsService;
|
||||
let prisma: PrismaService;
|
||||
let countryId: bigint;
|
||||
let categoryId: bigint;
|
||||
const createdIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PositionsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(PositionsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const stamp = Date.now();
|
||||
const c = await prisma.country.create({
|
||||
data: { countryName: `Pos Country ${stamp}` },
|
||||
});
|
||||
countryId = c.id;
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Pos Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdIds.length) {
|
||||
await prisma.position.deleteMany({ where: { id: { in: createdIds } } });
|
||||
}
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates a position linked to country + category and returns joined names', async () => {
|
||||
const created = await service.create({
|
||||
indexVal: 1,
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
createdIds.push(created.id);
|
||||
|
||||
expect(created.country?.countryName).toBeTruthy();
|
||||
expect(created.category?.categoryName).toBeTruthy();
|
||||
|
||||
const fetched = await service.findOne(created.id);
|
||||
expect(fetched.country?.id).toBe(countryId);
|
||||
expect(fetched.category?.id).toBe(categoryId);
|
||||
});
|
||||
|
||||
it('filters by countryId and categoryId', async () => {
|
||||
const list = await service.findAll({
|
||||
countryId,
|
||||
categoryId,
|
||||
});
|
||||
expect(list.length).toBeGreaterThan(0);
|
||||
expect(list.every((p) => p.countryId === countryId)).toBe(true);
|
||||
expect(list.every((p) => p.categoryId === categoryId)).toBe(true);
|
||||
});
|
||||
|
||||
it('updates indexVal and disconnects country', async () => {
|
||||
const created = await service.create({
|
||||
indexVal: 5,
|
||||
countryId: Number(countryId),
|
||||
});
|
||||
createdIds.push(created.id);
|
||||
const updated = await service.update(created.id, {
|
||||
indexVal: 9,
|
||||
countryId: null,
|
||||
});
|
||||
expect(updated.indexVal).toBe(9);
|
||||
expect(updated.countryId).toBeNull();
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown country during create', async () => {
|
||||
await expect(
|
||||
service.create({ indexVal: 1, countryId: 99999999 }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('deletes a position', async () => {
|
||||
const created = await service.create({ indexVal: 7 });
|
||||
await service.remove(created.id);
|
||||
const list = await service.findAll();
|
||||
expect(list.find((p) => p.id === created.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { PositionsService } from './positions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('PositionsService', () => {
|
||||
let service: PositionsService;
|
||||
let prisma: PrismaService;
|
||||
let countryId: bigint;
|
||||
let categoryId: bigint;
|
||||
const createdIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PositionsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(PositionsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const stamp = Date.now();
|
||||
const c = await prisma.country.create({
|
||||
data: { countryName: `Pos Country ${stamp}` },
|
||||
});
|
||||
countryId = c.id;
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Pos Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdIds.length) {
|
||||
await prisma.position.deleteMany({ where: { id: { in: createdIds } } });
|
||||
}
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates a position linked to country + category and returns joined names', async () => {
|
||||
const created = await service.create({
|
||||
indexVal: 1,
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
createdIds.push(created.id);
|
||||
|
||||
expect(created.country?.countryName).toBeTruthy();
|
||||
expect(created.category?.categoryName).toBeTruthy();
|
||||
|
||||
const fetched = await service.findOne(created.id);
|
||||
expect(fetched.country?.id).toBe(countryId);
|
||||
expect(fetched.category?.id).toBe(categoryId);
|
||||
});
|
||||
|
||||
it('filters by countryId and categoryId', async () => {
|
||||
const list = await service.findAll({
|
||||
countryId,
|
||||
categoryId,
|
||||
});
|
||||
expect(list.length).toBeGreaterThan(0);
|
||||
expect(list.every((p) => p.countryId === countryId)).toBe(true);
|
||||
expect(list.every((p) => p.categoryId === categoryId)).toBe(true);
|
||||
});
|
||||
|
||||
it('updates indexVal and disconnects country', async () => {
|
||||
const created = await service.create({
|
||||
indexVal: 5,
|
||||
countryId: Number(countryId),
|
||||
});
|
||||
createdIds.push(created.id);
|
||||
const updated = await service.update(created.id, {
|
||||
indexVal: 9,
|
||||
countryId: null,
|
||||
});
|
||||
expect(updated.indexVal).toBe(9);
|
||||
expect(updated.countryId).toBeNull();
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown country during create', async () => {
|
||||
await expect(
|
||||
service.create({ indexVal: 1, countryId: 99999999 }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('deletes a position', async () => {
|
||||
const created = await service.create({ indexVal: 7 });
|
||||
await service.remove(created.id);
|
||||
const list = await service.findAll();
|
||||
expect(list.find((p) => p.id === created.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,93 +1,93 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePositionDto } from './dto/create-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PositionsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(filters?: { countryId?: bigint; categoryId?: bigint }) {
|
||||
const where: { countryId?: bigint; categoryId?: bigint } = {};
|
||||
if (filters?.countryId !== undefined) where.countryId = filters.countryId;
|
||||
if (filters?.categoryId !== undefined) where.categoryId = filters.categoryId;
|
||||
return this.prisma.position.findMany({
|
||||
where,
|
||||
include: { country: true, category: true },
|
||||
orderBy: { indexVal: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const p = await this.prisma.position.findUnique({
|
||||
where: { id },
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException(`Position ${id} not found`);
|
||||
return p;
|
||||
}
|
||||
|
||||
async create(dto: CreatePositionDto) {
|
||||
if (dto.countryId !== undefined) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
}
|
||||
if (dto.categoryId !== undefined) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
}
|
||||
return this.prisma.position.create({
|
||||
data: {
|
||||
indexVal: dto.indexVal,
|
||||
countryId: dto.countryId === undefined ? null : BigInt(dto.countryId),
|
||||
categoryId: dto.categoryId === undefined ? null : BigInt(dto.categoryId),
|
||||
},
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdatePositionDto) {
|
||||
await this.findOne(id);
|
||||
if (dto.countryId !== undefined && dto.countryId !== null) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
}
|
||||
if (dto.categoryId !== undefined && dto.categoryId !== null) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
}
|
||||
return this.prisma.position.update({
|
||||
where: { id },
|
||||
data: {
|
||||
indexVal: dto.indexVal,
|
||||
country:
|
||||
dto.countryId === undefined
|
||||
? undefined
|
||||
: dto.countryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.countryId) } },
|
||||
category:
|
||||
dto.categoryId === undefined
|
||||
? undefined
|
||||
: dto.categoryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.categoryId) } },
|
||||
},
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.position.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureCountry(id: number) {
|
||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new NotFoundException(`Country ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCategory(id: number) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
||||
}
|
||||
}
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePositionDto } from './dto/create-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PositionsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(filters?: { countryId?: bigint; categoryId?: bigint }) {
|
||||
const where: { countryId?: bigint; categoryId?: bigint } = {};
|
||||
if (filters?.countryId !== undefined) where.countryId = filters.countryId;
|
||||
if (filters?.categoryId !== undefined) where.categoryId = filters.categoryId;
|
||||
return this.prisma.position.findMany({
|
||||
where,
|
||||
include: { country: true, category: true },
|
||||
orderBy: { indexVal: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const p = await this.prisma.position.findUnique({
|
||||
where: { id },
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException(`Position ${id} not found`);
|
||||
return p;
|
||||
}
|
||||
|
||||
async create(dto: CreatePositionDto) {
|
||||
if (dto.countryId !== undefined) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
}
|
||||
if (dto.categoryId !== undefined) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
}
|
||||
return this.prisma.position.create({
|
||||
data: {
|
||||
indexVal: dto.indexVal,
|
||||
countryId: dto.countryId === undefined ? null : BigInt(dto.countryId),
|
||||
categoryId: dto.categoryId === undefined ? null : BigInt(dto.categoryId),
|
||||
},
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdatePositionDto) {
|
||||
await this.findOne(id);
|
||||
if (dto.countryId !== undefined && dto.countryId !== null) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
}
|
||||
if (dto.categoryId !== undefined && dto.categoryId !== null) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
}
|
||||
return this.prisma.position.update({
|
||||
where: { id },
|
||||
data: {
|
||||
indexVal: dto.indexVal,
|
||||
country:
|
||||
dto.countryId === undefined
|
||||
? undefined
|
||||
: dto.countryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.countryId) } },
|
||||
category:
|
||||
dto.categoryId === undefined
|
||||
? undefined
|
||||
: dto.categoryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.categoryId) } },
|
||||
},
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.position.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureCountry(id: number) {
|
||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new NotFoundException(`Country ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCategory(id: number) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
/**
|
||||
* Global Prisma module exporting {@link PrismaService}.
|
||||
*
|
||||
* Marked `@Global()` so feature modules do not need to import it again.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
/**
|
||||
* Global Prisma module exporting {@link PrismaService}.
|
||||
*
|
||||
* Marked `@Global()` so feature modules do not need to import it again.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
describe('PrismaService', () => {
|
||||
let service: PrismaService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaService],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(PrismaService);
|
||||
await service.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should be able to run a trivial query against the database', async () => {
|
||||
// Execute a raw query that does not depend on application tables
|
||||
const result = await service.$queryRaw<Array<{ now: Date }>>`SELECT now() AS now`;
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].now).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
describe('PrismaService', () => {
|
||||
let service: PrismaService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaService],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(PrismaService);
|
||||
await service.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should be able to run a trivial query against the database', async () => {
|
||||
// Execute a raw query that does not depend on application tables
|
||||
const result = await service.$queryRaw<Array<{ now: Date }>>`SELECT now() AS now`;
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].now).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Global Prisma client wrapper.
|
||||
*
|
||||
* - `onModuleInit`: connects to the database so the first request
|
||||
* does not pay the connection cost.
|
||||
* - `onModuleDestroy`: cleanly disconnects during shutdown.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Global Prisma client wrapper.
|
||||
*
|
||||
* - `onModuleInit`: connects to the database so the first request
|
||||
* does not pay the connection cost.
|
||||
* - `onModuleDestroy`: cleanly disconnects during shutdown.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Category as PrismaCategory } from '@prisma/client';
|
||||
|
||||
export class PublicCategoryNodeDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
categoryIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
parentCategoryId!: string | null;
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Category as PrismaCategory } from '@prisma/client';
|
||||
|
||||
export class PublicCategoryNodeDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
categoryIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
parentCategoryId!: string | null;
|
||||
|
||||
@ApiProperty({ type: [PublicCategoryNodeDto] })
|
||||
children!: PublicCategoryNodeDto[];
|
||||
|
||||
@ApiProperty({ description: '当前节点及其后代分类的商品数量' })
|
||||
productCount!: number;
|
||||
|
||||
|
||||
static from(category: PrismaCategory, children: PublicCategoryNodeDto[] = []): PublicCategoryNodeDto {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
categoryName: category.categoryName,
|
||||
categoryIcon: category.categoryIcon,
|
||||
parentCategoryId: category.parentCategoryId
|
||||
? category.parentCategoryId.toString()
|
||||
: null,
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
categoryName: category.categoryName,
|
||||
categoryIcon: category.categoryIcon,
|
||||
parentCategoryId: category.parentCategoryId
|
||||
? category.parentCategoryId.toString()
|
||||
: null,
|
||||
children,
|
||||
productCount: 0,
|
||||
};
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Country as PrismaCountry } from '@prisma/client';
|
||||
|
||||
export class PublicCountryDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
countryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
countryIcon!: string | null;
|
||||
|
||||
static from(country: PrismaCountry): PublicCountryDto {
|
||||
return {
|
||||
id: country.id.toString(),
|
||||
countryName: country.countryName,
|
||||
countryIcon: country.countryIcon,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Country as PrismaCountry } from '@prisma/client';
|
||||
|
||||
export class PublicCountryDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
countryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
countryIcon!: string | null;
|
||||
|
||||
static from(country: PrismaCountry): PublicCountryDto {
|
||||
return {
|
||||
id: country.id.toString(),
|
||||
countryName: country.countryName,
|
||||
countryIcon: country.countryIcon,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ export class PublicGoodDetailDto extends PublicGoodDto {
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
media!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ type: Array, description: 'Variant images grouped by color' })
|
||||
mediaByColor!: Array<{
|
||||
colorId: string | null;
|
||||
colorName: string | null;
|
||||
colorHex: string | null;
|
||||
images: string[];
|
||||
}>;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
options!: Record<string, unknown> | null;
|
||||
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PublicGoodDto {
|
||||
@ApiProperty()
|
||||
goodId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodPriority!: number;
|
||||
|
||||
@ApiProperty()
|
||||
country!: { id: string; countryName: string; countryIcon: string | null };
|
||||
|
||||
@ApiProperty()
|
||||
category!: { id: string; categoryName: string; categoryIcon: string | null };
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tag!: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null } | null;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null }>;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
position!: { id: string; indexVal: number } | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
image!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
price!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodPriority!: number;
|
||||
|
||||
@ApiProperty()
|
||||
country!: { id: string; countryName: string; countryIcon: string | null };
|
||||
|
||||
@ApiProperty()
|
||||
category!: { id: string; categoryName: string; categoryIcon: string | null };
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tag!: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null } | null;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null }>;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
position!: { id: string; indexVal: number } | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
image!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
price!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { TagGroup as PrismaTagGroup } from '@prisma/client';
|
||||
|
||||
export class PublicTagGroupDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupColor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
|
||||
static from(g: PrismaTagGroup): PublicTagGroupDto {
|
||||
return {
|
||||
id: g.id.toString(),
|
||||
groupName: g.groupName,
|
||||
groupIcon: g.groupIcon,
|
||||
groupColor: g.groupColor,
|
||||
sortOrder: g.sortOrder,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { TagGroup as PrismaTagGroup } from '@prisma/client';
|
||||
|
||||
export class PublicTagGroupDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupColor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
|
||||
static from(g: PrismaTagGroup): PublicTagGroupDto {
|
||||
return {
|
||||
id: g.id.toString(),
|
||||
groupName: g.groupName,
|
||||
groupIcon: g.groupIcon,
|
||||
groupColor: g.groupColor,
|
||||
sortOrder: g.sortOrder,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Tag as PrismaTag } from '@prisma/client';
|
||||
|
||||
export class PublicTagDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
tagName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagColor!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagFontColor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
group!: { id: string; groupName: string; sortOrder: number } | null;
|
||||
|
||||
static from(tag: PrismaTag & { tagGroup?: { id: bigint; groupName: string; sortOrder: number } | null }): PublicTagDto {
|
||||
return {
|
||||
id: tag.id.toString(),
|
||||
tagName: tag.tagName,
|
||||
tagColor: tag.tagColor,
|
||||
tagFontColor: tag.tagFontColor,
|
||||
sortOrder: tag.sortOrder,
|
||||
group: tag.tagGroup
|
||||
? {
|
||||
id: tag.tagGroup.id.toString(),
|
||||
groupName: tag.tagGroup.groupName,
|
||||
sortOrder: tag.tagGroup.sortOrder,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Tag as PrismaTag } from '@prisma/client';
|
||||
|
||||
export class PublicTagDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
tagName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagColor!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagFontColor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
group!: { id: string; groupName: string; sortOrder: number } | null;
|
||||
|
||||
static from(tag: PrismaTag & { tagGroup?: { id: bigint; groupName: string; sortOrder: number } | null }): PublicTagDto {
|
||||
return {
|
||||
id: tag.id.toString(),
|
||||
tagName: tag.tagName,
|
||||
tagColor: tag.tagColor,
|
||||
tagFontColor: tag.tagFontColor,
|
||||
sortOrder: tag.sortOrder,
|
||||
group: tag.tagGroup
|
||||
? {
|
||||
id: tag.tagGroup.id.toString(),
|
||||
groupName: tag.tagGroup.groupName,
|
||||
sortOrder: tag.tagGroup.sortOrder,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,48 +13,48 @@ import {
|
||||
ApiTags,
|
||||
getSchemaPath,
|
||||
} from '@nestjs/swagger';
|
||||
import { PublicService } from './public.service';
|
||||
import { PublicService } from './public.service';
|
||||
import {
|
||||
PublicCountryQueryDto,
|
||||
PublicHomeGoodsQueryDto,
|
||||
PublicQueryGoodDto,
|
||||
PublicTagFilterDto,
|
||||
} from './dto/public-query-good.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
|
||||
import { PublicGoodDto } from './dto/public-good.dto';
|
||||
|
||||
|
||||
@ApiTags('public')
|
||||
@ApiExtraModels(PublicTagFilterDto)
|
||||
@Controller('public')
|
||||
export class PublicController {
|
||||
constructor(private readonly service: PublicService) {}
|
||||
|
||||
export class PublicController {
|
||||
constructor(private readonly service: PublicService) {}
|
||||
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: '获取商品分类树;countryId 不传时返回全部国家' })
|
||||
getCategories(@Query() query: PublicCountryQueryDto) {
|
||||
return this.service.getCategoriesTree(query.countryId);
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
@ApiOperation({ summary: 'Public list of countries that have goods' })
|
||||
getCountries() {
|
||||
return this.service.getCountries();
|
||||
}
|
||||
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: 'Public list of tags that have goods' })
|
||||
getTags(): Promise<PublicTagDto[]> {
|
||||
return this.service.getTags();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
@ApiOperation({ summary: 'Public list of countries that have goods' })
|
||||
getCountries() {
|
||||
return this.service.getCountries();
|
||||
}
|
||||
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: 'Public list of tags that have goods' })
|
||||
getTags(): Promise<PublicTagDto[]> {
|
||||
return this.service.getTags();
|
||||
}
|
||||
|
||||
@Get('tag-groups')
|
||||
@ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' })
|
||||
@ApiOkResponse({ type: [PublicTagGroupFilterDto] })
|
||||
getTagGroups(@Query() query: PublicCountryQueryDto): Promise<PublicTagGroupFilterDto[]> {
|
||||
return this.service.getTagGroups(query.countryId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Get('goods')
|
||||
@ApiOperation({ summary: '分页获取商品' })
|
||||
@ApiQuery({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PublicController } from './public.controller';
|
||||
import { PublicService } from './public.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PublicController],
|
||||
providers: [PublicService],
|
||||
})
|
||||
export class PublicModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PublicController } from './public.controller';
|
||||
import { PublicService } from './public.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PublicController],
|
||||
providers: [PublicService],
|
||||
})
|
||||
export class PublicModule {}
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PublicService } from './public.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('PublicService', () => {
|
||||
let service: PublicService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
let countryId: bigint;
|
||||
let categoryId: bigint;
|
||||
let childCategoryId: bigint;
|
||||
let otherCategoryId: bigint;
|
||||
import { PublicService } from './public.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('PublicService', () => {
|
||||
let service: PublicService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
let countryId: bigint;
|
||||
let categoryId: bigint;
|
||||
let childCategoryId: bigint;
|
||||
let otherCategoryId: bigint;
|
||||
let tagId: bigint;
|
||||
let filterGroupIds: bigint[] = [];
|
||||
let filterTagIds: bigint[] = [];
|
||||
let originGoodId: bigint;
|
||||
let goodIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PublicService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(PublicService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const country = await prisma.country.create({
|
||||
data: { countryName: `Pub Country ${stamp}` },
|
||||
});
|
||||
countryId = country.id;
|
||||
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Pub Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
const child = await prisma.category.create({
|
||||
data: { categoryName: `Pub Child ${stamp}`, parentCategoryId: cat.id },
|
||||
});
|
||||
childCategoryId = child.id;
|
||||
const otherCat = await prisma.category.create({
|
||||
data: { categoryName: `Pub Other ${stamp}` },
|
||||
});
|
||||
otherCategoryId = otherCat.id;
|
||||
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName: `Pub Tag ${stamp}`, tagColor: '#0000FF' },
|
||||
});
|
||||
tagId = tag.id;
|
||||
|
||||
const og = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `pub-sds-${stamp}`,
|
||||
goodName: `Origin ${stamp}`,
|
||||
goodImage: 'http://img',
|
||||
},
|
||||
});
|
||||
originGoodId = og.id;
|
||||
|
||||
// Seed 3 goods:
|
||||
// high priority + position.indexVal=1
|
||||
// mid priority + position.indexVal=5
|
||||
// no priority + no position (falls back to createdAt)
|
||||
const pos1 = await prisma.position.create({
|
||||
data: { indexVal: 1, countryId, categoryId },
|
||||
});
|
||||
const pos2 = await prisma.position.create({
|
||||
data: { indexVal: 5, countryId, categoryId },
|
||||
});
|
||||
|
||||
const g1 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub High ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 10,
|
||||
positionId: pos1.id,
|
||||
},
|
||||
});
|
||||
const g2 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Mid ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 5,
|
||||
positionId: pos2.id,
|
||||
},
|
||||
});
|
||||
const g3 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub NoPos ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
tagId,
|
||||
goodPriority: 1,
|
||||
},
|
||||
});
|
||||
let originGoodId: bigint;
|
||||
let goodIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PublicService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(PublicService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const country = await prisma.country.create({
|
||||
data: { countryName: `Pub Country ${stamp}` },
|
||||
});
|
||||
countryId = country.id;
|
||||
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Pub Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
const child = await prisma.category.create({
|
||||
data: { categoryName: `Pub Child ${stamp}`, parentCategoryId: cat.id },
|
||||
});
|
||||
childCategoryId = child.id;
|
||||
const otherCat = await prisma.category.create({
|
||||
data: { categoryName: `Pub Other ${stamp}` },
|
||||
});
|
||||
otherCategoryId = otherCat.id;
|
||||
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName: `Pub Tag ${stamp}`, tagColor: '#0000FF' },
|
||||
});
|
||||
tagId = tag.id;
|
||||
|
||||
const og = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `pub-sds-${stamp}`,
|
||||
goodName: `Origin ${stamp}`,
|
||||
goodImage: 'http://img',
|
||||
},
|
||||
});
|
||||
originGoodId = og.id;
|
||||
|
||||
// Seed 3 goods:
|
||||
// high priority + position.indexVal=1
|
||||
// mid priority + position.indexVal=5
|
||||
// no priority + no position (falls back to createdAt)
|
||||
const pos1 = await prisma.position.create({
|
||||
data: { indexVal: 1, countryId, categoryId },
|
||||
});
|
||||
const pos2 = await prisma.position.create({
|
||||
data: { indexVal: 5, countryId, categoryId },
|
||||
});
|
||||
|
||||
const g1 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub High ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 10,
|
||||
positionId: pos1.id,
|
||||
},
|
||||
});
|
||||
const g2 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Mid ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 5,
|
||||
positionId: pos2.id,
|
||||
},
|
||||
});
|
||||
const g3 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub NoPos ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
tagId,
|
||||
goodPriority: 1,
|
||||
},
|
||||
});
|
||||
goodIds = [g1.id, g2.id, g3.id];
|
||||
|
||||
const craftGroup = await prisma.tagGroup.create({
|
||||
@@ -143,102 +143,102 @@ describe('PublicService', () => {
|
||||
price: 38,
|
||||
},
|
||||
});
|
||||
|
||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||
// returns more than one category.
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Other ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId: otherCategoryId,
|
||||
goodPriority: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// And seed a good in the *child* category, to verify categoryId
|
||||
// recursion.
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub ChildGood ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId: childCategoryId,
|
||||
goodPriority: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (goodIds.length) {
|
||||
await prisma.good.deleteMany({ where: { id: { in: goodIds } } });
|
||||
}
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Pub ` } },
|
||||
});
|
||||
await prisma.position.deleteMany({
|
||||
where: { countryId },
|
||||
});
|
||||
|
||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||
// returns more than one category.
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Other ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId: otherCategoryId,
|
||||
goodPriority: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// And seed a good in the *child* category, to verify categoryId
|
||||
// recursion.
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub ChildGood ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId: childCategoryId,
|
||||
goodPriority: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (goodIds.length) {
|
||||
await prisma.good.deleteMany({ where: { id: { in: goodIds } } });
|
||||
}
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Pub ` } },
|
||||
});
|
||||
await prisma.position.deleteMany({
|
||||
where: { countryId },
|
||||
});
|
||||
await prisma.tag.delete({ where: { id: tagId } });
|
||||
await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } });
|
||||
await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } });
|
||||
await prisma.originGood.delete({ where: { id: originGoodId } });
|
||||
// Delete children before parent (FK self-relation is RESTRICT).
|
||||
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||
await prisma.category.delete({ where: { id: otherCategoryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('getCategoriesTree returns only categories that have goods', async () => {
|
||||
const tree = await service.getCategoriesTree();
|
||||
const allIds = new Set<string>();
|
||||
const walk = (list: Array<{ id: string; children: Array<{ id: string }> }>) => {
|
||||
for (const n of list) {
|
||||
allIds.add(n.id);
|
||||
walk(n.children as any);
|
||||
}
|
||||
};
|
||||
walk(tree as any);
|
||||
// We seeded goods in `categoryId`, `childCategoryId`, `otherCategoryId`.
|
||||
expect(allIds.has(categoryId.toString())).toBe(true);
|
||||
expect(allIds.has(childCategoryId.toString())).toBe(true);
|
||||
expect(allIds.has(otherCategoryId.toString())).toBe(true);
|
||||
});
|
||||
|
||||
it('getCountries returns only countries that have goods', async () => {
|
||||
const countries = await service.getCountries();
|
||||
expect(countries.find((c) => c.id === countryId.toString())).toBeDefined();
|
||||
});
|
||||
|
||||
it('filters by countryId, tagId, keyword and categoryId (recursively)', async () => {
|
||||
const filtered = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
await prisma.originGood.delete({ where: { id: originGoodId } });
|
||||
// Delete children before parent (FK self-relation is RESTRICT).
|
||||
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||
await prisma.category.delete({ where: { id: otherCategoryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('getCategoriesTree returns only categories that have goods', async () => {
|
||||
const tree = await service.getCategoriesTree();
|
||||
const allIds = new Set<string>();
|
||||
const walk = (list: Array<{ id: string; children: Array<{ id: string }> }>) => {
|
||||
for (const n of list) {
|
||||
allIds.add(n.id);
|
||||
walk(n.children as any);
|
||||
}
|
||||
};
|
||||
walk(tree as any);
|
||||
// We seeded goods in `categoryId`, `childCategoryId`, `otherCategoryId`.
|
||||
expect(allIds.has(categoryId.toString())).toBe(true);
|
||||
expect(allIds.has(childCategoryId.toString())).toBe(true);
|
||||
expect(allIds.has(otherCategoryId.toString())).toBe(true);
|
||||
});
|
||||
|
||||
it('getCountries returns only countries that have goods', async () => {
|
||||
const countries = await service.getCountries();
|
||||
expect(countries.find((c) => c.id === countryId.toString())).toBeDefined();
|
||||
});
|
||||
|
||||
it('filters by countryId, tagId, keyword and categoryId (recursively)', async () => {
|
||||
const filtered = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
categoryId: categoryId.toString(), // includes child
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
||||
expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true);
|
||||
});
|
||||
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
||||
expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true);
|
||||
});
|
||||
|
||||
it('sorts by priority DESC, position.indexVal ASC, createdAt DESC', async () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
});
|
||||
const priorities = result.items.map((g) => g.goodPriority);
|
||||
// First verify primary descending priority.
|
||||
const sorted = [...priorities].sort((a, b) => b - a);
|
||||
expect(priorities).toEqual(sorted);
|
||||
keyword: `Pub `,
|
||||
});
|
||||
const priorities = result.items.map((g) => g.goodPriority);
|
||||
// First verify primary descending priority.
|
||||
const sorted = [...priorities].sort((a, b) => b - a);
|
||||
expect(priorities).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('uses OR within one tag group and AND across tag groups', async () => {
|
||||
@@ -292,16 +292,16 @@ describe('PublicService', () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
|
||||
it('returns the SDS product id as the public product id', async () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub High ${stamp}`,
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
keyword: `Pub High ${stamp}`,
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
|
||||
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
||||
});
|
||||
@@ -335,15 +335,15 @@ describe('PublicService', () => {
|
||||
await prisma.originGood.delete({ where: { id: origin.id } });
|
||||
}
|
||||
});
|
||||
|
||||
it('getGood returns detail and 404 for unknown id', async () => {
|
||||
const first = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
|
||||
it('getGood returns detail and 404 for unknown id', async () => {
|
||||
const first = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(first.items.length).toBe(1);
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(first.items.length).toBe(1);
|
||||
const detail = await service.getGood(`pub-sds-${stamp}`);
|
||||
expect(detail.goodId).toBe(first.items[0].goodId);
|
||||
expect(detail.productCode).toBe('OZ10827003');
|
||||
@@ -353,30 +353,30 @@ describe('PublicService', () => {
|
||||
expect(detail.variants).toHaveLength(1);
|
||||
|
||||
await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('getTags returns tags with their group info, sorted by group then order', async () => {
|
||||
const tags = await service.getTags();
|
||||
expect(tags.length).toBeGreaterThan(0);
|
||||
// Each tag in our seed (包邮/不包邮/...) should have a group
|
||||
const grouped = tags.find((t) => t.tagName === '包邮');
|
||||
if (grouped) {
|
||||
expect(grouped.group).not.toBeNull();
|
||||
expect(grouped.group!.groupName).toBe('物流渠道');
|
||||
}
|
||||
});
|
||||
|
||||
it('getTagGroups returns only groups that have goods', async () => {
|
||||
const groups = await service.getTagGroups();
|
||||
expect(groups.length).toBeGreaterThan(0);
|
||||
const names = groups.map((g) => g.groupName);
|
||||
expect(names).toContain('物流渠道');
|
||||
expect(names).toContain('印刷位置');
|
||||
expect(names).toContain('印刷工艺');
|
||||
// Sorted by sortOrder
|
||||
const sortOrders = groups.map((g) => g.sortOrder);
|
||||
expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders);
|
||||
});
|
||||
});
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('getTags returns tags with their group info, sorted by group then order', async () => {
|
||||
const tags = await service.getTags();
|
||||
expect(tags.length).toBeGreaterThan(0);
|
||||
// Each tag in our seed (包邮/不包邮/...) should have a group
|
||||
const grouped = tags.find((t) => t.tagName === '包邮');
|
||||
if (grouped) {
|
||||
expect(grouped.group).not.toBeNull();
|
||||
expect(grouped.group!.groupName).toBe('物流渠道');
|
||||
}
|
||||
});
|
||||
|
||||
it('getTagGroups returns only groups that have goods', async () => {
|
||||
const groups = await service.getTagGroups();
|
||||
expect(groups.length).toBeGreaterThan(0);
|
||||
const names = groups.map((g) => g.groupName);
|
||||
expect(names).toContain('物流渠道');
|
||||
expect(names).toContain('印刷位置');
|
||||
expect(names).toContain('印刷工艺');
|
||||
// Sorted by sortOrder
|
||||
const sortOrders = groups.map((g) => g.sortOrder);
|
||||
expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,7 +205,9 @@ export class PublicService {
|
||||
if (!good) {
|
||||
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||
}
|
||||
return this.toPublicGoodDetail(good);
|
||||
const dto = this.toPublicGoodDetail(good);
|
||||
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
|
||||
return dto;
|
||||
}
|
||||
|
||||
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
|
||||
@@ -270,6 +272,66 @@ export class PublicService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Group distinct variant images by color so the frontend can switch media per color.
|
||||
* Only color-specific photos are included (main / result / detail images);
|
||||
* design-layer素材图 and the product-level blank garment photo are excluded
|
||||
* because they are not per-color gallery photos. */
|
||||
private groupImagesByColor(
|
||||
variants: PublicGoodRow['originGood']['variants'],
|
||||
): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> {
|
||||
const groups = new Map<string, {
|
||||
colorId: string | null;
|
||||
colorName: string | null;
|
||||
colorHex: string | null;
|
||||
images: string[];
|
||||
}>();
|
||||
for (const variant of variants) {
|
||||
const key = variant.colorId ?? `variant:${variant.sdsVariantId}`;
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
colorId: variant.colorId,
|
||||
colorName: variant.colorName,
|
||||
colorHex: variant.colorHex,
|
||||
images: [],
|
||||
};
|
||||
groups.set(key, group);
|
||||
}
|
||||
const design = (variant.designData ?? {}) as {
|
||||
detailImgUrls?: Array<{ imageUrl?: unknown }>;
|
||||
prototypeResultGroups?: Array<{ resultImage?: unknown }>;
|
||||
};
|
||||
const urls: unknown[] = [
|
||||
variant.imageUrl,
|
||||
...(design.prototypeResultGroups ?? []).map((item) => item?.resultImage),
|
||||
...(design.detailImgUrls ?? []).map((image) => image?.imageUrl),
|
||||
];
|
||||
for (const url of urls) {
|
||||
const value = typeof url === 'string' ? url.trim() : '';
|
||||
if (value && !group.images.includes(value)) {
|
||||
group.images.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/** Leaf categories often have no icon upstream; fall back to the nearest ancestor that has one. */
|
||||
private async resolveCategoryIcon(category: PublicGoodRow['category']): Promise<string | null> {
|
||||
if (category.categoryIcon) return category.categoryIcon;
|
||||
let cursor = category.parentCategoryId;
|
||||
for (let depth = 0; cursor !== null && depth < 10; depth++) {
|
||||
const parent = await this.prisma.category.findUnique({
|
||||
where: { id: cursor },
|
||||
select: { categoryIcon: true, parentCategoryId: true },
|
||||
});
|
||||
if (!parent) break;
|
||||
if (parent.categoryIcon) return parent.categoryIcon;
|
||||
cursor = parent.parentCategoryId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
|
||||
const base = this.toPublicGood(good);
|
||||
const detail = good.originGood.detail;
|
||||
@@ -292,6 +354,7 @@ export class PublicService {
|
||||
pictureRequest: detail?.pictureRequest ?? null,
|
||||
},
|
||||
media: (detail?.media as Record<string, unknown> | null) ?? null,
|
||||
mediaByColor: this.groupImagesByColor(good.originGood.variants),
|
||||
options: (detail?.options as Record<string, unknown> | null) ?? null,
|
||||
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
|
||||
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null,
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { SyncLog } from '@prisma/client';
|
||||
|
||||
export class SyncLogDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { SyncLog } from '@prisma/client';
|
||||
|
||||
export class SyncLogDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
type!: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS';
|
||||
|
||||
@ApiProperty()
|
||||
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
message!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
startedAt!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
finishedAt!: string | null;
|
||||
|
||||
static from(log: SyncLog): SyncLogDto {
|
||||
return {
|
||||
id: log.id.toString(),
|
||||
type: log.type,
|
||||
status: log.status,
|
||||
message: log.message,
|
||||
startedAt: log.startedAt.toISOString(),
|
||||
finishedAt: log.finishedAt ? log.finishedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiProperty()
|
||||
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
message!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
startedAt!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
finishedAt!: string | null;
|
||||
|
||||
static from(log: SyncLog): SyncLogDto {
|
||||
return {
|
||||
id: log.id.toString(),
|
||||
type: log.type,
|
||||
status: log.status,
|
||||
message: log.message,
|
||||
startedAt: log.startedAt.toISOString(),
|
||||
finishedAt: log.finishedAt ? log.finishedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
|
||||
describe('SdsClientService', () => {
|
||||
let service: SdsClientService;
|
||||
let http: { post: jest.Mock; get: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
http = { post: jest.fn(), get: jest.fn() };
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
SdsClientService,
|
||||
{ provide: HttpService, useValue: http },
|
||||
{ provide: ConfigService, useValue: { get: jest.fn(() => undefined) } },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SdsClientService);
|
||||
});
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
|
||||
describe('SdsClientService', () => {
|
||||
let service: SdsClientService;
|
||||
let http: { post: jest.Mock; get: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
http = { post: jest.fn(), get: jest.fn() };
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
SdsClientService,
|
||||
{ provide: HttpService, useValue: http },
|
||||
{ provide: ConfigService, useValue: { get: jest.fn(() => undefined) } },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SdsClientService);
|
||||
});
|
||||
|
||||
describe('fetchCategoryTree', () => {
|
||||
it('throws when the upstream returns a degenerate small tree', async () => {
|
||||
http.post.mockReturnValue(of({ data: [{ id: 1, name: 'Only' }] }));
|
||||
await expect(service.fetchCategoryTree()).rejects.toThrow(/degenerate/i);
|
||||
});
|
||||
|
||||
it('returns the tree when it is healthy', async () => {
|
||||
const tree = Array.from({ length: 20 }, (_, i) => ({ id: i + 1, name: `C${i}` }));
|
||||
http.post.mockReturnValue(of({ data: tree }));
|
||||
const result = await service.fetchCategoryTree();
|
||||
expect(result).toHaveLength(20);
|
||||
});
|
||||
it('throws when the upstream returns a degenerate small tree', async () => {
|
||||
http.post.mockReturnValue(of({ data: [{ id: 1, name: 'Only' }] }));
|
||||
await expect(service.fetchCategoryTree()).rejects.toThrow(/degenerate/i);
|
||||
});
|
||||
|
||||
it('returns the tree when it is healthy', async () => {
|
||||
const tree = Array.from({ length: 20 }, (_, i) => ({ id: i + 1, name: `C${i}` }));
|
||||
http.post.mockReturnValue(of({ data: tree }));
|
||||
const result = await service.fetchCategoryTree();
|
||||
expect(result).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchProductDetail', () => {
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
const POD_HEADERS = {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
Origin: 'https://inkpod.vip',
|
||||
Referer: 'https://inkpod.vip/',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Minimum number of category nodes a healthy `category/tree/3` response contains.
|
||||
* Below this the response is treated as degenerate and rejected so the caller
|
||||
* never runs a destructive sync against a partial tree.
|
||||
*/
|
||||
export const MIN_SDS_CATEGORY_NODES = 10;
|
||||
|
||||
export interface SdsCategoryTreeNode {
|
||||
id: number | string | null;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
children?: SdsCategoryTreeNode[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SdsProduct {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
pic?: string;
|
||||
image?: string;
|
||||
psd_img_url?: string;
|
||||
thumbImgUrl?: string;
|
||||
blankDesignUrl?: string;
|
||||
img_url?: string;
|
||||
show_img?: string;
|
||||
price?: number | string;
|
||||
currentPrice?: number | string;
|
||||
categoryId?: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
const POD_HEADERS = {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
Origin: 'https://inkpod.vip',
|
||||
Referer: 'https://inkpod.vip/',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Minimum number of category nodes a healthy `category/tree/3` response contains.
|
||||
* Below this the response is treated as degenerate and rejected so the caller
|
||||
* never runs a destructive sync against a partial tree.
|
||||
*/
|
||||
export const MIN_SDS_CATEGORY_NODES = 10;
|
||||
|
||||
export interface SdsCategoryTreeNode {
|
||||
id: number | string | null;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
children?: SdsCategoryTreeNode[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SdsProduct {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
pic?: string;
|
||||
image?: string;
|
||||
psd_img_url?: string;
|
||||
thumbImgUrl?: string;
|
||||
blankDesignUrl?: string;
|
||||
img_url?: string;
|
||||
show_img?: string;
|
||||
price?: number | string;
|
||||
currentPrice?: number | string;
|
||||
categoryId?: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SdsProductsPage {
|
||||
items?: SdsProduct[];
|
||||
content?: SdsProduct[];
|
||||
totalCount?: number;
|
||||
totalElements?: number;
|
||||
total?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
[key: string]: unknown;
|
||||
items?: SdsProduct[];
|
||||
content?: SdsProduct[];
|
||||
totalCount?: number;
|
||||
totalElements?: number;
|
||||
total?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SdsProductVariant extends Record<string, unknown> {
|
||||
@@ -131,90 +131,90 @@ export interface SdsProductDetail extends Record<string, unknown> {
|
||||
items?: SdsProductVariant[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
|
||||
* `SyncService` consumes.
|
||||
*
|
||||
* Exposed as its own service so it can be mocked cleanly in unit tests.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SdsClientService {
|
||||
private readonly logger = new Logger(SdsClientService.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
this.baseUrl =
|
||||
config.get<string>('SDS_API_BASE')?.replace(/\/$/, '') ??
|
||||
'https://mapi.sdspod.com';
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: 'post' | 'get',
|
||||
url: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const MAX_RETRIES = 3;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const config = {
|
||||
headers: POD_HEADERS,
|
||||
timeout: 30000,
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
const obs =
|
||||
method === 'post'
|
||||
? this.http.post<T>(url, body, config)
|
||||
: this.http.get<T>(url, config);
|
||||
const { data } = await firstValueFrom(obs);
|
||||
return data as T;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (attempt < MAX_RETRIES) {
|
||||
this.logger.warn(`SDS request attempt ${attempt}/${MAX_RETRIES} failed: ${msg}`);
|
||||
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async fetchCategoryTree(): Promise<SdsCategoryTreeNode[]> {
|
||||
const url = `${this.baseUrl}/category/tree/3`;
|
||||
const body = {
|
||||
withActivityArea: true,
|
||||
withPrivate: true,
|
||||
onlyHaveProduct: true,
|
||||
};
|
||||
const data = await this.request<unknown>('post', url, body);
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`SDS category tree returned ${typeof data}, expected array`);
|
||||
}
|
||||
if (data.length < MIN_SDS_CATEGORY_NODES) {
|
||||
throw new Error(
|
||||
`SDS category tree is degenerate (${data.length} nodes < ${MIN_SDS_CATEGORY_NODES}) — ` +
|
||||
`aborting to avoid destructive sync`,
|
||||
);
|
||||
}
|
||||
return data as SdsCategoryTreeNode[];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
|
||||
* `SyncService` consumes.
|
||||
*
|
||||
* Exposed as its own service so it can be mocked cleanly in unit tests.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SdsClientService {
|
||||
private readonly logger = new Logger(SdsClientService.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
this.baseUrl =
|
||||
config.get<string>('SDS_API_BASE')?.replace(/\/$/, '') ??
|
||||
'https://mapi.sdspod.com';
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: 'post' | 'get',
|
||||
url: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const MAX_RETRIES = 3;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const config = {
|
||||
headers: POD_HEADERS,
|
||||
timeout: 30000,
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
const obs =
|
||||
method === 'post'
|
||||
? this.http.post<T>(url, body, config)
|
||||
: this.http.get<T>(url, config);
|
||||
const { data } = await firstValueFrom(obs);
|
||||
return data as T;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (attempt < MAX_RETRIES) {
|
||||
this.logger.warn(`SDS request attempt ${attempt}/${MAX_RETRIES} failed: ${msg}`);
|
||||
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async fetchCategoryTree(): Promise<SdsCategoryTreeNode[]> {
|
||||
const url = `${this.baseUrl}/category/tree/3`;
|
||||
const body = {
|
||||
withActivityArea: true,
|
||||
withPrivate: true,
|
||||
onlyHaveProduct: true,
|
||||
};
|
||||
const data = await this.request<unknown>('post', url, body);
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`SDS category tree returned ${typeof data}, expected array`);
|
||||
}
|
||||
if (data.length < MIN_SDS_CATEGORY_NODES) {
|
||||
throw new Error(
|
||||
`SDS category tree is degenerate (${data.length} nodes < ${MIN_SDS_CATEGORY_NODES}) — ` +
|
||||
`aborting to avoid destructive sync`,
|
||||
);
|
||||
}
|
||||
return data as SdsCategoryTreeNode[];
|
||||
}
|
||||
|
||||
async fetchProductsPage(
|
||||
categoryId: string | number,
|
||||
page = 1,
|
||||
size = 50,
|
||||
): Promise<SdsProductsPage> {
|
||||
const url = `${this.baseUrl}/products/page`;
|
||||
const data = await this.request<unknown>('get', url, undefined, { categoryId, page, size });
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error(`SDS products page returned ${typeof data}, expected object`);
|
||||
}
|
||||
categoryId: string | number,
|
||||
page = 1,
|
||||
size = 50,
|
||||
): Promise<SdsProductsPage> {
|
||||
const url = `${this.baseUrl}/products/page`;
|
||||
const data = await this.request<unknown>('get', url, undefined, { categoryId, page, size });
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error(`SDS products page returned ${typeof data}, expected object`);
|
||||
}
|
||||
return data as SdsProductsPage;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
import {
|
||||
Controller,
|
||||
import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SyncLogDto } from './dto/sync-log.dto';
|
||||
|
||||
@ApiTags('sync')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('sync')
|
||||
export class SyncController {
|
||||
constructor(private readonly service: SyncService) {}
|
||||
|
||||
@Post('categories')
|
||||
@ApiOperation({ summary: 'Manually trigger category sync (async)' })
|
||||
async syncCategories() {
|
||||
return this.service.startCategorySync();
|
||||
}
|
||||
|
||||
@Post('products')
|
||||
@ApiOperation({ summary: 'Manually trigger product sync (async)' })
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SyncLogDto } from './dto/sync-log.dto';
|
||||
|
||||
@ApiTags('sync')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('sync')
|
||||
export class SyncController {
|
||||
constructor(private readonly service: SyncService) {}
|
||||
|
||||
@Post('categories')
|
||||
@ApiOperation({ summary: 'Manually trigger category sync (async)' })
|
||||
async syncCategories() {
|
||||
return this.service.startCategorySync();
|
||||
}
|
||||
|
||||
@Post('products')
|
||||
@ApiOperation({ summary: 'Manually trigger product sync (async)' })
|
||||
async syncProducts() {
|
||||
return this.service.startProductSync();
|
||||
}
|
||||
@@ -48,14 +48,14 @@ export class SyncController {
|
||||
async syncOneProductDetail(@Param('goodId') goodId: string) {
|
||||
return this.service.syncOneProductDetail(goodId);
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Recent sync log entries' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async status(
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
): Promise<SyncLogDto[]> {
|
||||
const logs = await this.service.getStatus(limit);
|
||||
return logs.map((l) => SyncLogDto.from(l));
|
||||
}
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Recent sync log entries' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async status(
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
): Promise<SyncLogDto[]> {
|
||||
const logs = await this.service.getStatus(limit);
|
||||
return logs.map((l) => SyncLogDto.from(l));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), HttpModule],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService, SdsClientService],
|
||||
exports: [SyncService, SdsClientService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), HttpModule],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService, SdsClientService],
|
||||
exports: [SyncService, SdsClientService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
|
||||
@@ -1,280 +1,280 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import {
|
||||
SyncService,
|
||||
shouldRunDelistDetection,
|
||||
shouldSkipStaleDeletion,
|
||||
} from './sync.service';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import {
|
||||
SyncService,
|
||||
shouldRunDelistDetection,
|
||||
shouldSkipStaleDeletion,
|
||||
} from './sync.service';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('SyncService', () => {
|
||||
let service: SyncService;
|
||||
let sds: jest.Mocked<SdsClientService>;
|
||||
let prisma: PrismaService;
|
||||
const createdSdsCategoryIds: string[] = [];
|
||||
const createdSdsGoodIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const sdsMock: Partial<SdsClientService> = {
|
||||
let service: SyncService;
|
||||
let sds: jest.Mocked<SdsClientService>;
|
||||
let prisma: PrismaService;
|
||||
const createdSdsCategoryIds: string[] = [];
|
||||
const createdSdsGoodIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const sdsMock: Partial<SdsClientService> = {
|
||||
fetchCategoryTree: jest.fn(),
|
||||
fetchProductsPage: jest.fn(),
|
||||
fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
|
||||
};
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
||||
providers: [
|
||||
SyncService,
|
||||
{ provide: SdsClientService, useValue: sdsMock },
|
||||
PrismaService,
|
||||
],
|
||||
}).compile();
|
||||
};
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
||||
providers: [
|
||||
SyncService,
|
||||
{ provide: SdsClientService, useValue: sdsMock },
|
||||
PrismaService,
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SyncService);
|
||||
jest
|
||||
.spyOn(service, 'syncConfiguredProductDetails')
|
||||
.mockResolvedValue({ synced: 0, failed: 0 });
|
||||
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdSdsCategoryIds.length) {
|
||||
await prisma.category.deleteMany({
|
||||
where: { sdsCategoryId: { in: createdSdsCategoryIds } },
|
||||
});
|
||||
}
|
||||
if (createdSdsGoodIds.length) {
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { sdsGoodId: { in: createdSdsGoodIds } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('flattenCategoryTree', () => {
|
||||
it('flattens a nested SDS tree and preserves parent linkage', () => {
|
||||
const tree = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Root',
|
||||
children: [
|
||||
{ id: 11, name: 'Child A' },
|
||||
{ id: 12, name: 'Child B', children: [{ id: 121, name: 'Leaf' }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
const flat = service.flattenCategoryTree(tree);
|
||||
expect(flat).toHaveLength(4);
|
||||
const byId = Object.fromEntries(flat.map((n) => [n.sdsId, n]));
|
||||
expect(byId['1'].name).toBe('Root');
|
||||
expect(byId['1'].parentSdsId).toBeUndefined();
|
||||
expect(byId['11'].parentSdsId).toBe('1');
|
||||
expect(byId['12'].parentSdsId).toBe('1');
|
||||
expect(byId['121'].parentSdsId).toBe('12');
|
||||
});
|
||||
|
||||
it('skips nodes without a usable id', () => {
|
||||
const flat = service.flattenCategoryTree([{ id: null, name: 'no-id' }]);
|
||||
expect(flat).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncCategories', () => {
|
||||
it('inserts + updates rows and links parents', async () => {
|
||||
const stamp = Date.now();
|
||||
sds.fetchCategoryTree.mockResolvedValueOnce([
|
||||
{
|
||||
id: `r-${stamp}`,
|
||||
name: `Root ${stamp}`,
|
||||
children: [{ id: `c-${stamp}`, name: `Child ${stamp}` }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.syncCategories();
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.inserted).toBe(2);
|
||||
expect(result.updated).toBe(0);
|
||||
createdSdsCategoryIds.push(`r-${stamp}`, `c-${stamp}`);
|
||||
|
||||
const child = await prisma.category.findUnique({
|
||||
where: { sdsCategoryId: `c-${stamp}` },
|
||||
});
|
||||
const root = await prisma.category.findUnique({
|
||||
where: { sdsCategoryId: `r-${stamp}` },
|
||||
});
|
||||
expect(child?.parentCategoryId).toBe(root?.id);
|
||||
});
|
||||
|
||||
it('marks SyncLog SUCCESS', async () => {
|
||||
const logs = await prisma.syncLog.findMany({
|
||||
where: { type: 'CATEGORIES' },
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
expect(logs[0]?.status).toBe('SUCCESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProducts', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('upserts origin goods by sdsGoodId and updates on re-run', async () => {
|
||||
// Seed a leaf category with sdsCategoryId so the sync has work.
|
||||
const stamp = Date.now();
|
||||
const leaf = await prisma.category.create({
|
||||
data: {
|
||||
sdsCategoryId: `leaf-${stamp}`,
|
||||
categoryName: `Leaf ${stamp}`,
|
||||
},
|
||||
});
|
||||
createdSdsCategoryIds.push(`leaf-${stamp}`);
|
||||
|
||||
// Default mock returns a single page with 2 products, then
|
||||
// breaks the loop because content.length < 50.
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leaf-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `p-${stamp}-1`, name: 'Product 1', price: 12.5, pic: 'http://x' },
|
||||
{ id: `p-${stamp}-2`, name: 'Product 2', price: '99.00' },
|
||||
],
|
||||
};
|
||||
}
|
||||
// For any other (already-existing) category, return empty
|
||||
// so the loop terminates immediately.
|
||||
return { content: [] };
|
||||
});
|
||||
|
||||
const result1 = await service.syncProducts();
|
||||
expect(result1.inserted).toBeGreaterThanOrEqual(2);
|
||||
createdSdsGoodIds.push(`p-${stamp}-1`, `p-${stamp}-2`);
|
||||
|
||||
// Re-run with updated name -> should be `updated`, not `inserted`.
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leaf-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `p-${stamp}-1`, name: 'Product 1 renamed' },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: [] };
|
||||
});
|
||||
const result2 = await service.syncProducts();
|
||||
expect(result2.updated).toBeGreaterThanOrEqual(1);
|
||||
const row = await prisma.originGood.findUnique({
|
||||
where: { sdsGoodId: `p-${stamp}-1` },
|
||||
});
|
||||
expect(row?.goodName).toBe('Product 1 renamed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync guard thresholds', () => {
|
||||
describe('shouldSkipStaleDeletion', () => {
|
||||
it('skips stale deletion when the fetched count is below the hard floor', () => {
|
||||
expect(shouldSkipStaleDeletion(2, 226)).toBe(true);
|
||||
expect(shouldSkipStaleDeletion(9, 226)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips stale deletion when fetched is far smaller than existing (ratio guard)', () => {
|
||||
expect(shouldSkipStaleDeletion(100, 250)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT skip when fetched count is healthy', () => {
|
||||
expect(shouldSkipStaleDeletion(226, 226)).toBe(false);
|
||||
expect(shouldSkipStaleDeletion(200, 226)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT skip when there are no existing SDS categories', () => {
|
||||
expect(shouldSkipStaleDeletion(0, 0)).toBe(false);
|
||||
expect(shouldSkipStaleDeletion(2, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRunDelistDetection', () => {
|
||||
it('skips delist detection when leaf categories are too few', () => {
|
||||
expect(shouldRunDelistDetection(2, 500)).toBe(false);
|
||||
expect(shouldRunDelistDetection(9, 500)).toBe(false);
|
||||
});
|
||||
|
||||
it('skips delist detection when the seen product count is too small', () => {
|
||||
expect(shouldRunDelistDetection(148, 2)).toBe(false);
|
||||
expect(shouldRunDelistDetection(148, 49)).toBe(false);
|
||||
});
|
||||
|
||||
it('runs delist detection only when both metrics are healthy', () => {
|
||||
expect(shouldRunDelistDetection(148, 500)).toBe(true);
|
||||
expect(shouldRunDelistDetection(10, 50)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('category sync keeps existing SDS categories when upstream returns a degenerate tree', async () => {
|
||||
const stamp = Date.now();
|
||||
const keep = await prisma.category.create({
|
||||
data: { sdsCategoryId: `keep-${stamp}`, categoryName: `Keep ${stamp}` },
|
||||
});
|
||||
createdSdsCategoryIds.push(`keep-${stamp}`);
|
||||
|
||||
sds.fetchCategoryTree.mockResolvedValueOnce([
|
||||
{ id: `g-${stamp}-1`, name: 'Tiny 1' },
|
||||
{ id: `g-${stamp}-2`, name: 'Tiny 2' },
|
||||
]);
|
||||
createdSdsCategoryIds.push(`g-${stamp}-1`, `g-${stamp}-2`);
|
||||
|
||||
const result = await service.syncCategories();
|
||||
expect(result.deletedStale).toBe(0);
|
||||
|
||||
const still = await prisma.category.findUnique({ where: { id: keep.id } });
|
||||
expect(still).not.toBeNull();
|
||||
});
|
||||
|
||||
it('product sync does NOT delist origin goods when it sees too few products', async () => {
|
||||
const stamp = Date.now();
|
||||
const leaf = await prisma.category.create({
|
||||
data: { sdsCategoryId: `leafguard-${stamp}`, categoryName: `LeafGuard ${stamp}` },
|
||||
});
|
||||
createdSdsCategoryIds.push(`leafguard-${stamp}`);
|
||||
|
||||
const active = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `active-${stamp}`, delisted: false, goodName: 'Active' },
|
||||
});
|
||||
createdSdsGoodIds.push(`active-${stamp}`);
|
||||
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leafguard-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `guardp-${stamp}-1`, name: 'P1' },
|
||||
{ id: `guardp-${stamp}-2`, name: 'P2' },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: [] };
|
||||
});
|
||||
|
||||
const result = await service.syncProducts();
|
||||
expect(result.delisted).toBe(0);
|
||||
|
||||
const still = await prisma.originGood.findUnique({ where: { id: active.id } });
|
||||
expect(still?.delisted).toBe(false);
|
||||
createdSdsGoodIds.push(`guardp-${stamp}-1`, `guardp-${stamp}-2`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns recent logs ordered by startedAt desc', async () => {
|
||||
const logs = await service.getStatus(5);
|
||||
expect(Array.isArray(logs)).toBe(true);
|
||||
expect(logs.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdSdsCategoryIds.length) {
|
||||
await prisma.category.deleteMany({
|
||||
where: { sdsCategoryId: { in: createdSdsCategoryIds } },
|
||||
});
|
||||
}
|
||||
if (createdSdsGoodIds.length) {
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { sdsGoodId: { in: createdSdsGoodIds } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('flattenCategoryTree', () => {
|
||||
it('flattens a nested SDS tree and preserves parent linkage', () => {
|
||||
const tree = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Root',
|
||||
children: [
|
||||
{ id: 11, name: 'Child A' },
|
||||
{ id: 12, name: 'Child B', children: [{ id: 121, name: 'Leaf' }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
const flat = service.flattenCategoryTree(tree);
|
||||
expect(flat).toHaveLength(4);
|
||||
const byId = Object.fromEntries(flat.map((n) => [n.sdsId, n]));
|
||||
expect(byId['1'].name).toBe('Root');
|
||||
expect(byId['1'].parentSdsId).toBeUndefined();
|
||||
expect(byId['11'].parentSdsId).toBe('1');
|
||||
expect(byId['12'].parentSdsId).toBe('1');
|
||||
expect(byId['121'].parentSdsId).toBe('12');
|
||||
});
|
||||
|
||||
it('skips nodes without a usable id', () => {
|
||||
const flat = service.flattenCategoryTree([{ id: null, name: 'no-id' }]);
|
||||
expect(flat).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncCategories', () => {
|
||||
it('inserts + updates rows and links parents', async () => {
|
||||
const stamp = Date.now();
|
||||
sds.fetchCategoryTree.mockResolvedValueOnce([
|
||||
{
|
||||
id: `r-${stamp}`,
|
||||
name: `Root ${stamp}`,
|
||||
children: [{ id: `c-${stamp}`, name: `Child ${stamp}` }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.syncCategories();
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.inserted).toBe(2);
|
||||
expect(result.updated).toBe(0);
|
||||
createdSdsCategoryIds.push(`r-${stamp}`, `c-${stamp}`);
|
||||
|
||||
const child = await prisma.category.findUnique({
|
||||
where: { sdsCategoryId: `c-${stamp}` },
|
||||
});
|
||||
const root = await prisma.category.findUnique({
|
||||
where: { sdsCategoryId: `r-${stamp}` },
|
||||
});
|
||||
expect(child?.parentCategoryId).toBe(root?.id);
|
||||
});
|
||||
|
||||
it('marks SyncLog SUCCESS', async () => {
|
||||
const logs = await prisma.syncLog.findMany({
|
||||
where: { type: 'CATEGORIES' },
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
expect(logs[0]?.status).toBe('SUCCESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProducts', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('upserts origin goods by sdsGoodId and updates on re-run', async () => {
|
||||
// Seed a leaf category with sdsCategoryId so the sync has work.
|
||||
const stamp = Date.now();
|
||||
const leaf = await prisma.category.create({
|
||||
data: {
|
||||
sdsCategoryId: `leaf-${stamp}`,
|
||||
categoryName: `Leaf ${stamp}`,
|
||||
},
|
||||
});
|
||||
createdSdsCategoryIds.push(`leaf-${stamp}`);
|
||||
|
||||
// Default mock returns a single page with 2 products, then
|
||||
// breaks the loop because content.length < 50.
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leaf-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `p-${stamp}-1`, name: 'Product 1', price: 12.5, pic: 'http://x' },
|
||||
{ id: `p-${stamp}-2`, name: 'Product 2', price: '99.00' },
|
||||
],
|
||||
};
|
||||
}
|
||||
// For any other (already-existing) category, return empty
|
||||
// so the loop terminates immediately.
|
||||
return { content: [] };
|
||||
});
|
||||
|
||||
const result1 = await service.syncProducts();
|
||||
expect(result1.inserted).toBeGreaterThanOrEqual(2);
|
||||
createdSdsGoodIds.push(`p-${stamp}-1`, `p-${stamp}-2`);
|
||||
|
||||
// Re-run with updated name -> should be `updated`, not `inserted`.
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leaf-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `p-${stamp}-1`, name: 'Product 1 renamed' },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: [] };
|
||||
});
|
||||
const result2 = await service.syncProducts();
|
||||
expect(result2.updated).toBeGreaterThanOrEqual(1);
|
||||
const row = await prisma.originGood.findUnique({
|
||||
where: { sdsGoodId: `p-${stamp}-1` },
|
||||
});
|
||||
expect(row?.goodName).toBe('Product 1 renamed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync guard thresholds', () => {
|
||||
describe('shouldSkipStaleDeletion', () => {
|
||||
it('skips stale deletion when the fetched count is below the hard floor', () => {
|
||||
expect(shouldSkipStaleDeletion(2, 226)).toBe(true);
|
||||
expect(shouldSkipStaleDeletion(9, 226)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips stale deletion when fetched is far smaller than existing (ratio guard)', () => {
|
||||
expect(shouldSkipStaleDeletion(100, 250)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT skip when fetched count is healthy', () => {
|
||||
expect(shouldSkipStaleDeletion(226, 226)).toBe(false);
|
||||
expect(shouldSkipStaleDeletion(200, 226)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT skip when there are no existing SDS categories', () => {
|
||||
expect(shouldSkipStaleDeletion(0, 0)).toBe(false);
|
||||
expect(shouldSkipStaleDeletion(2, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRunDelistDetection', () => {
|
||||
it('skips delist detection when leaf categories are too few', () => {
|
||||
expect(shouldRunDelistDetection(2, 500)).toBe(false);
|
||||
expect(shouldRunDelistDetection(9, 500)).toBe(false);
|
||||
});
|
||||
|
||||
it('skips delist detection when the seen product count is too small', () => {
|
||||
expect(shouldRunDelistDetection(148, 2)).toBe(false);
|
||||
expect(shouldRunDelistDetection(148, 49)).toBe(false);
|
||||
});
|
||||
|
||||
it('runs delist detection only when both metrics are healthy', () => {
|
||||
expect(shouldRunDelistDetection(148, 500)).toBe(true);
|
||||
expect(shouldRunDelistDetection(10, 50)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('category sync keeps existing SDS categories when upstream returns a degenerate tree', async () => {
|
||||
const stamp = Date.now();
|
||||
const keep = await prisma.category.create({
|
||||
data: { sdsCategoryId: `keep-${stamp}`, categoryName: `Keep ${stamp}` },
|
||||
});
|
||||
createdSdsCategoryIds.push(`keep-${stamp}`);
|
||||
|
||||
sds.fetchCategoryTree.mockResolvedValueOnce([
|
||||
{ id: `g-${stamp}-1`, name: 'Tiny 1' },
|
||||
{ id: `g-${stamp}-2`, name: 'Tiny 2' },
|
||||
]);
|
||||
createdSdsCategoryIds.push(`g-${stamp}-1`, `g-${stamp}-2`);
|
||||
|
||||
const result = await service.syncCategories();
|
||||
expect(result.deletedStale).toBe(0);
|
||||
|
||||
const still = await prisma.category.findUnique({ where: { id: keep.id } });
|
||||
expect(still).not.toBeNull();
|
||||
});
|
||||
|
||||
it('product sync does NOT delist origin goods when it sees too few products', async () => {
|
||||
const stamp = Date.now();
|
||||
const leaf = await prisma.category.create({
|
||||
data: { sdsCategoryId: `leafguard-${stamp}`, categoryName: `LeafGuard ${stamp}` },
|
||||
});
|
||||
createdSdsCategoryIds.push(`leafguard-${stamp}`);
|
||||
|
||||
const active = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `active-${stamp}`, delisted: false, goodName: 'Active' },
|
||||
});
|
||||
createdSdsGoodIds.push(`active-${stamp}`);
|
||||
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leafguard-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `guardp-${stamp}-1`, name: 'P1' },
|
||||
{ id: `guardp-${stamp}-2`, name: 'P2' },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: [] };
|
||||
});
|
||||
|
||||
const result = await service.syncProducts();
|
||||
expect(result.delisted).toBe(0);
|
||||
|
||||
const still = await prisma.originGood.findUnique({ where: { id: active.id } });
|
||||
expect(still?.delisted).toBe(false);
|
||||
createdSdsGoodIds.push(`guardp-${stamp}-1`, `guardp-${stamp}-2`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns recent logs ordered by startedAt desc', async () => {
|
||||
const logs = await service.getStatus(5);
|
||||
expect(Array.isArray(logs)).toBe(true);
|
||||
expect(logs.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncService product detail scopes', () => {
|
||||
|
||||
+743
-736
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTagGroupDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupIcon?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Hex color' })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
groupColor?: string;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTagGroupDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupIcon?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Hex color' })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
groupColor?: string;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsInt, Min, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class TagGroupOrderItem {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder!: number;
|
||||
}
|
||||
|
||||
export class ReorderTagGroupsDto {
|
||||
@ApiProperty({ type: [TagGroupOrderItem] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TagGroupOrderItem)
|
||||
items!: TagGroupOrderItem[];
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsInt, Min, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class TagGroupOrderItem {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder!: number;
|
||||
}
|
||||
|
||||
export class ReorderTagGroupsDto {
|
||||
@ApiProperty({ type: [TagGroupOrderItem] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TagGroupOrderItem)
|
||||
items!: TagGroupOrderItem[];
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateTagGroupDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupIcon?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
groupColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateTagGroupDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupIcon?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
groupColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
|
||||
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
|
||||
import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto';
|
||||
|
||||
@ApiTags('tag-groups')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('tag-groups')
|
||||
export class TagGroupsController {
|
||||
constructor(private readonly service: TagGroupsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all tag groups' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one tag group' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
// IMPORTANT: must come BEFORE ':id' to avoid being captured as id
|
||||
@Patch('sort')
|
||||
@ApiOperation({ summary: 'Batch update sort order' })
|
||||
reorder(@Body() dto: ReorderTagGroupsDto) {
|
||||
return this.service.reorder(dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a tag group' })
|
||||
create(@Body() dto: CreateTagGroupDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a tag group' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateTagGroupDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a tag group (tags become ungrouped)' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
|
||||
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
|
||||
import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto';
|
||||
|
||||
@ApiTags('tag-groups')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('tag-groups')
|
||||
export class TagGroupsController {
|
||||
constructor(private readonly service: TagGroupsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all tag groups' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one tag group' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
// IMPORTANT: must come BEFORE ':id' to avoid being captured as id
|
||||
@Patch('sort')
|
||||
@ApiOperation({ summary: 'Batch update sort order' })
|
||||
reorder(@Body() dto: ReorderTagGroupsDto) {
|
||||
return this.service.reorder(dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a tag group' })
|
||||
create(@Body() dto: CreateTagGroupDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a tag group' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateTagGroupDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a tag group (tags become ungrouped)' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagGroupsController } from './tag-groups.controller';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagGroupsController],
|
||||
providers: [TagGroupsService],
|
||||
exports: [TagGroupsService],
|
||||
})
|
||||
export class TagGroupsModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagGroupsController } from './tag-groups.controller';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagGroupsController],
|
||||
providers: [TagGroupsService],
|
||||
exports: [TagGroupsService],
|
||||
})
|
||||
export class TagGroupsModule {}
|
||||
|
||||
@@ -1,109 +1,109 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('TagGroupsService', () => {
|
||||
let service: TagGroupsService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [TagGroupsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(TagGroupsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.tagGroup.deleteMany({ where: { groupName: { in: createdNames } } });
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates and finds a tag group', async () => {
|
||||
const name = `Group ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const created = await service.create({ groupName: name, sortOrder: 99 });
|
||||
expect(created.groupName).toBe(name);
|
||||
expect(created.sortOrder).toBe(99);
|
||||
|
||||
const found = await service.findOne(created.id);
|
||||
expect(found.groupName).toBe(name);
|
||||
});
|
||||
|
||||
it('rejects duplicate group names', async () => {
|
||||
const name = `Dup ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
await service.create({ groupName: name });
|
||||
await expect(service.create({ groupName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates group name and color', async () => {
|
||||
const name = `Upd ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const g = await service.create({ groupName: name });
|
||||
const updated = await service.update(g.id, {
|
||||
groupName: `${name}-v2`,
|
||||
groupColor: '#FF8800',
|
||||
});
|
||||
expect(updated.groupName).toBe(`${name}-v2`);
|
||||
expect(updated.groupColor).toBe('#FF8800');
|
||||
});
|
||||
|
||||
it('reorders multiple groups', async () => {
|
||||
const a = `Reorder A ${Date.now()}`;
|
||||
const b = `Reorder B ${Date.now()}`;
|
||||
createdNames.push(a, b);
|
||||
const ga = await service.create({ groupName: a, sortOrder: 1 });
|
||||
const gb = await service.create({ groupName: b, sortOrder: 2 });
|
||||
|
||||
await service.reorder({
|
||||
items: [
|
||||
{ id: Number(ga.id), sortOrder: 5 },
|
||||
{ id: Number(gb.id), sortOrder: 6 },
|
||||
],
|
||||
});
|
||||
|
||||
const refreshedA = await prisma.tagGroup.findUnique({ where: { id: ga.id } });
|
||||
const refreshedB = await prisma.tagGroup.findUnique({ where: { id: gb.id } });
|
||||
expect(refreshedA!.sortOrder).toBe(5);
|
||||
expect(refreshedB!.sortOrder).toBe(6);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for non-existent group', async () => {
|
||||
await expect(service.findOne(BigInt(999999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a group; tags become ungrouped (SetNull)', async () => {
|
||||
const name = `Del ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const g = await service.create({ groupName: name });
|
||||
|
||||
// Create a tag assigned to it
|
||||
const tagName = `TagInGroup ${Date.now()}`;
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName, tagGroupId: g.id },
|
||||
});
|
||||
|
||||
await service.remove(g.id);
|
||||
|
||||
const refreshedTag = await prisma.tag.findUnique({ where: { id: tag.id } });
|
||||
expect(refreshedTag).not.toBeNull();
|
||||
expect(refreshedTag!.tagGroupId).toBeNull();
|
||||
|
||||
// cleanup
|
||||
await prisma.tag.delete({ where: { id: tag.id } });
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('TagGroupsService', () => {
|
||||
let service: TagGroupsService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [TagGroupsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(TagGroupsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.tagGroup.deleteMany({ where: { groupName: { in: createdNames } } });
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates and finds a tag group', async () => {
|
||||
const name = `Group ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const created = await service.create({ groupName: name, sortOrder: 99 });
|
||||
expect(created.groupName).toBe(name);
|
||||
expect(created.sortOrder).toBe(99);
|
||||
|
||||
const found = await service.findOne(created.id);
|
||||
expect(found.groupName).toBe(name);
|
||||
});
|
||||
|
||||
it('rejects duplicate group names', async () => {
|
||||
const name = `Dup ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
await service.create({ groupName: name });
|
||||
await expect(service.create({ groupName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates group name and color', async () => {
|
||||
const name = `Upd ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const g = await service.create({ groupName: name });
|
||||
const updated = await service.update(g.id, {
|
||||
groupName: `${name}-v2`,
|
||||
groupColor: '#FF8800',
|
||||
});
|
||||
expect(updated.groupName).toBe(`${name}-v2`);
|
||||
expect(updated.groupColor).toBe('#FF8800');
|
||||
});
|
||||
|
||||
it('reorders multiple groups', async () => {
|
||||
const a = `Reorder A ${Date.now()}`;
|
||||
const b = `Reorder B ${Date.now()}`;
|
||||
createdNames.push(a, b);
|
||||
const ga = await service.create({ groupName: a, sortOrder: 1 });
|
||||
const gb = await service.create({ groupName: b, sortOrder: 2 });
|
||||
|
||||
await service.reorder({
|
||||
items: [
|
||||
{ id: Number(ga.id), sortOrder: 5 },
|
||||
{ id: Number(gb.id), sortOrder: 6 },
|
||||
],
|
||||
});
|
||||
|
||||
const refreshedA = await prisma.tagGroup.findUnique({ where: { id: ga.id } });
|
||||
const refreshedB = await prisma.tagGroup.findUnique({ where: { id: gb.id } });
|
||||
expect(refreshedA!.sortOrder).toBe(5);
|
||||
expect(refreshedB!.sortOrder).toBe(6);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for non-existent group', async () => {
|
||||
await expect(service.findOne(BigInt(999999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a group; tags become ungrouped (SetNull)', async () => {
|
||||
const name = `Del ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const g = await service.create({ groupName: name });
|
||||
|
||||
// Create a tag assigned to it
|
||||
const tagName = `TagInGroup ${Date.now()}`;
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName, tagGroupId: g.id },
|
||||
});
|
||||
|
||||
await service.remove(g.id);
|
||||
|
||||
const refreshedTag = await prisma.tag.findUnique({ where: { id: tag.id } });
|
||||
expect(refreshedTag).not.toBeNull();
|
||||
expect(refreshedTag!.tagGroupId).toBeNull();
|
||||
|
||||
// cleanup
|
||||
await prisma.tag.delete({ where: { id: tag.id } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,88 +1,88 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
|
||||
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
|
||||
import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TagGroupsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.tagGroup.findMany({
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: { _count: { select: { tags: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const g = await this.prisma.tagGroup.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { tags: true } } },
|
||||
});
|
||||
if (!g) throw new NotFoundException(`TagGroup ${id} not found`);
|
||||
return g;
|
||||
}
|
||||
|
||||
async create(dto: CreateTagGroupDto) {
|
||||
try {
|
||||
return await this.prisma.tagGroup.create({
|
||||
data: {
|
||||
groupName: dto.groupName,
|
||||
groupIcon: dto.groupIcon ?? null,
|
||||
groupColor: dto.groupColor ?? null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag group name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateTagGroupDto) {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.TagGroupUpdateInput = {};
|
||||
if (dto.groupName !== undefined) data.groupName = dto.groupName;
|
||||
if (dto.groupIcon !== undefined) data.groupIcon = dto.groupIcon;
|
||||
if (dto.groupColor !== undefined) data.groupColor = dto.groupColor;
|
||||
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||
try {
|
||||
return await this.prisma.tagGroup.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag group name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.tagGroup.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderTagGroupsDto) {
|
||||
return this.prisma.$transaction(
|
||||
dto.items.map((item) =>
|
||||
this.prisma.tagGroup.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: { sortOrder: item.sortOrder },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
|
||||
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
|
||||
import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TagGroupsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.tagGroup.findMany({
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: { _count: { select: { tags: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const g = await this.prisma.tagGroup.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { tags: true } } },
|
||||
});
|
||||
if (!g) throw new NotFoundException(`TagGroup ${id} not found`);
|
||||
return g;
|
||||
}
|
||||
|
||||
async create(dto: CreateTagGroupDto) {
|
||||
try {
|
||||
return await this.prisma.tagGroup.create({
|
||||
data: {
|
||||
groupName: dto.groupName,
|
||||
groupIcon: dto.groupIcon ?? null,
|
||||
groupColor: dto.groupColor ?? null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag group name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateTagGroupDto) {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.TagGroupUpdateInput = {};
|
||||
if (dto.groupName !== undefined) data.groupName = dto.groupName;
|
||||
if (dto.groupIcon !== undefined) data.groupIcon = dto.groupIcon;
|
||||
if (dto.groupColor !== undefined) data.groupColor = dto.groupColor;
|
||||
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||
try {
|
||||
return await this.prisma.tagGroup.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag group name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.tagGroup.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderTagGroupsDto) {
|
||||
return this.prisma.$transaction(
|
||||
dto.items.map((item) =>
|
||||
this.prisma.tagGroup.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: { sortOrder: item.sortOrder },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTagDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
tagName!: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
nullable: true,
|
||||
description: 'Hex color, e.g. #FF0000',
|
||||
example: '#FF8800',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagColor?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
nullable: true,
|
||||
description: 'Hex font color, e.g. #FFFFFF',
|
||||
example: '#FFFFFF',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagFontColor?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timing?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Tag group ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTagDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
tagName!: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
nullable: true,
|
||||
description: 'Hex color, e.g. #FF0000',
|
||||
example: '#FF8800',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagColor?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
nullable: true,
|
||||
description: 'Hex font color, e.g. #FFFFFF',
|
||||
example: '#FFFFFF',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagFontColor?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timing?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Tag group ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class TagOrderItem {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Move tag to this group (null = ungrouped)' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder!: number;
|
||||
}
|
||||
|
||||
export class ReorderTagsDto {
|
||||
@ApiProperty({ type: [TagOrderItem] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TagOrderItem)
|
||||
items!: TagOrderItem[];
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class TagOrderItem {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Move tag to this group (null = ungrouped)' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder!: number;
|
||||
}
|
||||
|
||||
export class ReorderTagsDto {
|
||||
@ApiProperty({ type: [TagOrderItem] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TagOrderItem)
|
||||
items!: TagOrderItem[];
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateTagDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tagName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagFontColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timing?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Tag group ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateTagDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tagName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagFontColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timing?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Tag group ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TagsService } from './tags.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
import { UpdateTagDto } from './dto/update-tag.dto';
|
||||
import { ReorderTagsDto } from './dto/reorder-tags.dto';
|
||||
|
||||
@ApiTags('tags')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('tags')
|
||||
export class TagsController {
|
||||
constructor(private readonly service: TagsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all tags' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one tag' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a tag' })
|
||||
create(@Body() dto: CreateTagDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
// IMPORTANT: must come BEFORE ':id' to avoid being captured as id
|
||||
@Patch('sort')
|
||||
@ApiOperation({ summary: 'Batch update sort order and/or group assignment' })
|
||||
reorder(@Body() dto: ReorderTagsDto) {
|
||||
return this.service.reorder(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a tag' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateTagDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a tag' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TagsService } from './tags.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
import { UpdateTagDto } from './dto/update-tag.dto';
|
||||
import { ReorderTagsDto } from './dto/reorder-tags.dto';
|
||||
|
||||
@ApiTags('tags')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('tags')
|
||||
export class TagsController {
|
||||
constructor(private readonly service: TagsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all tags' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one tag' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a tag' })
|
||||
create(@Body() dto: CreateTagDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
// IMPORTANT: must come BEFORE ':id' to avoid being captured as id
|
||||
@Patch('sort')
|
||||
@ApiOperation({ summary: 'Batch update sort order and/or group assignment' })
|
||||
reorder(@Body() dto: ReorderTagsDto) {
|
||||
return this.service.reorder(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a tag' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateTagDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a tag' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagsController } from './tags.controller';
|
||||
import { TagsService } from './tags.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagsController],
|
||||
providers: [TagsService],
|
||||
exports: [TagsService],
|
||||
})
|
||||
export class TagsModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagsController } from './tags.controller';
|
||||
import { TagsService } from './tags.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagsController],
|
||||
providers: [TagsService],
|
||||
exports: [TagsService],
|
||||
})
|
||||
export class TagsModule {}
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { TagsService } from './tags.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
|
||||
describe('TagsService', () => {
|
||||
let service: TagsService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [TagsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(TagsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.tag.deleteMany({ where: { tagName: { in: createdNames } } });
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates a tag and validates hex color via DTO', async () => {
|
||||
const name = `Tag ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const created = await service.create({ tagName: name, tagColor: '#FF8800' });
|
||||
expect(created.tagColor).toBe('#FF8800');
|
||||
|
||||
const invalid = plainToInstance(CreateTagDto, {
|
||||
tagName: 'invalid',
|
||||
tagColor: 'not-a-color',
|
||||
});
|
||||
const errors = await validate(invalid);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe('tagColor');
|
||||
});
|
||||
|
||||
it('rejects duplicate tag names with ConflictException', async () => {
|
||||
const name = `Dup ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
await service.create({ tagName: name });
|
||||
await expect(service.create({ tagName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates and deletes', async () => {
|
||||
const name = `Upd ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const tag = await service.create({ tagName: name });
|
||||
const updated = await service.update(tag.id, { tagName: `${name}-v2` });
|
||||
expect(updated.tagName).toBe(`${name}-v2`);
|
||||
await service.remove(tag.id);
|
||||
const idx = createdNames.indexOf(name);
|
||||
if (idx !== -1) createdNames[idx] = `${name}-v2`;
|
||||
});
|
||||
|
||||
it('assigns tagGroupId and sortOrder on create, supports reordering', async () => {
|
||||
// Use existing seeded groups
|
||||
const group = await prisma.tagGroup.findFirst({
|
||||
where: { groupName: '物流渠道' },
|
||||
});
|
||||
expect(group).not.toBeNull();
|
||||
|
||||
const a = `Grouped A ${Date.now()}`;
|
||||
const b = `Grouped B ${Date.now()}`;
|
||||
createdNames.push(a, b);
|
||||
|
||||
const tagA = await service.create({
|
||||
tagName: a,
|
||||
tagGroupId: Number(group!.id),
|
||||
sortOrder: 1,
|
||||
});
|
||||
expect(tagA.tagGroupId).toBe(group!.id);
|
||||
|
||||
const tagB = await service.create({
|
||||
tagName: b,
|
||||
tagGroupId: Number(group!.id),
|
||||
sortOrder: 2,
|
||||
});
|
||||
expect(tagB.sortOrder).toBe(2);
|
||||
|
||||
// Reorder swap
|
||||
await service.reorder({
|
||||
items: [
|
||||
{ id: Number(tagA.id), sortOrder: 2 },
|
||||
{ id: Number(tagB.id), sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const refreshedA = await prisma.tag.findUnique({ where: { id: tagA.id } });
|
||||
const refreshedB = await prisma.tag.findUnique({ where: { id: tagB.id } });
|
||||
expect(refreshedA!.sortOrder).toBe(2);
|
||||
expect(refreshedB!.sortOrder).toBe(1);
|
||||
});
|
||||
|
||||
it('removes a tag cleanly even when it is in a group', async () => {
|
||||
const group = await prisma.tagGroup.findFirst({
|
||||
where: { groupName: '印刷工艺' },
|
||||
});
|
||||
const name = `Grouped ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
|
||||
const tag = await service.create({
|
||||
tagName: name,
|
||||
tagGroupId: Number(group!.id),
|
||||
});
|
||||
await service.remove(tag.id);
|
||||
const found = await prisma.tag.findUnique({ where: { id: tag.id } });
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { TagsService } from './tags.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
|
||||
describe('TagsService', () => {
|
||||
let service: TagsService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [TagsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(TagsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.tag.deleteMany({ where: { tagName: { in: createdNames } } });
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates a tag and validates hex color via DTO', async () => {
|
||||
const name = `Tag ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const created = await service.create({ tagName: name, tagColor: '#FF8800' });
|
||||
expect(created.tagColor).toBe('#FF8800');
|
||||
|
||||
const invalid = plainToInstance(CreateTagDto, {
|
||||
tagName: 'invalid',
|
||||
tagColor: 'not-a-color',
|
||||
});
|
||||
const errors = await validate(invalid);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe('tagColor');
|
||||
});
|
||||
|
||||
it('rejects duplicate tag names with ConflictException', async () => {
|
||||
const name = `Dup ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
await service.create({ tagName: name });
|
||||
await expect(service.create({ tagName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates and deletes', async () => {
|
||||
const name = `Upd ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const tag = await service.create({ tagName: name });
|
||||
const updated = await service.update(tag.id, { tagName: `${name}-v2` });
|
||||
expect(updated.tagName).toBe(`${name}-v2`);
|
||||
await service.remove(tag.id);
|
||||
const idx = createdNames.indexOf(name);
|
||||
if (idx !== -1) createdNames[idx] = `${name}-v2`;
|
||||
});
|
||||
|
||||
it('assigns tagGroupId and sortOrder on create, supports reordering', async () => {
|
||||
// Use existing seeded groups
|
||||
const group = await prisma.tagGroup.findFirst({
|
||||
where: { groupName: '物流渠道' },
|
||||
});
|
||||
expect(group).not.toBeNull();
|
||||
|
||||
const a = `Grouped A ${Date.now()}`;
|
||||
const b = `Grouped B ${Date.now()}`;
|
||||
createdNames.push(a, b);
|
||||
|
||||
const tagA = await service.create({
|
||||
tagName: a,
|
||||
tagGroupId: Number(group!.id),
|
||||
sortOrder: 1,
|
||||
});
|
||||
expect(tagA.tagGroupId).toBe(group!.id);
|
||||
|
||||
const tagB = await service.create({
|
||||
tagName: b,
|
||||
tagGroupId: Number(group!.id),
|
||||
sortOrder: 2,
|
||||
});
|
||||
expect(tagB.sortOrder).toBe(2);
|
||||
|
||||
// Reorder swap
|
||||
await service.reorder({
|
||||
items: [
|
||||
{ id: Number(tagA.id), sortOrder: 2 },
|
||||
{ id: Number(tagB.id), sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const refreshedA = await prisma.tag.findUnique({ where: { id: tagA.id } });
|
||||
const refreshedB = await prisma.tag.findUnique({ where: { id: tagB.id } });
|
||||
expect(refreshedA!.sortOrder).toBe(2);
|
||||
expect(refreshedB!.sortOrder).toBe(1);
|
||||
});
|
||||
|
||||
it('removes a tag cleanly even when it is in a group', async () => {
|
||||
const group = await prisma.tagGroup.findFirst({
|
||||
where: { groupName: '印刷工艺' },
|
||||
});
|
||||
const name = `Grouped ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
|
||||
const tag = await service.create({
|
||||
tagName: name,
|
||||
tagGroupId: Number(group!.id),
|
||||
});
|
||||
await service.remove(tag.id);
|
||||
const found = await prisma.tag.findUnique({ where: { id: tag.id } });
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+104
-104
@@ -1,104 +1,104 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
import { UpdateTagDto } from './dto/update-tag.dto';
|
||||
import { ReorderTagsDto } from './dto/reorder-tags.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TagsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.tag.findMany({
|
||||
orderBy: [{ tagGroupId: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: { tagGroup: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const t = await this.prisma.tag.findUnique({
|
||||
where: { id },
|
||||
include: { tagGroup: true },
|
||||
});
|
||||
if (!t) throw new NotFoundException(`Tag ${id} not found`);
|
||||
return t;
|
||||
}
|
||||
|
||||
async create(dto: CreateTagDto) {
|
||||
try {
|
||||
return await this.prisma.tag.create({
|
||||
data: {
|
||||
tagName: dto.tagName,
|
||||
tagColor: dto.tagColor ?? null,
|
||||
tagFontColor: dto.tagFontColor ?? null,
|
||||
timing: dto.timing ?? null,
|
||||
tagGroupId: dto.tagGroupId ? BigInt(dto.tagGroupId) : null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateTagDto) {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.TagUpdateInput = {};
|
||||
if (dto.tagName !== undefined) data.tagName = dto.tagName;
|
||||
if (dto.tagColor !== undefined) data.tagColor = dto.tagColor;
|
||||
if (dto.tagFontColor !== undefined) data.tagFontColor = dto.tagFontColor;
|
||||
if (dto.timing !== undefined) data.timing = dto.timing;
|
||||
if (dto.tagGroupId !== undefined) {
|
||||
data.tagGroup = dto.tagGroupId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.tagGroupId) } };
|
||||
}
|
||||
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||
try {
|
||||
return await this.prisma.tag.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.tag.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderTagsDto) {
|
||||
return this.prisma.$transaction(
|
||||
dto.items.map((item) =>
|
||||
this.prisma.tag.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: {
|
||||
sortOrder: item.sortOrder,
|
||||
tagGroupId:
|
||||
item.tagGroupId === undefined
|
||||
? undefined
|
||||
: item.tagGroupId === null
|
||||
? null
|
||||
: BigInt(item.tagGroupId),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
import { UpdateTagDto } from './dto/update-tag.dto';
|
||||
import { ReorderTagsDto } from './dto/reorder-tags.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TagsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.tag.findMany({
|
||||
orderBy: [{ tagGroupId: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: { tagGroup: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const t = await this.prisma.tag.findUnique({
|
||||
where: { id },
|
||||
include: { tagGroup: true },
|
||||
});
|
||||
if (!t) throw new NotFoundException(`Tag ${id} not found`);
|
||||
return t;
|
||||
}
|
||||
|
||||
async create(dto: CreateTagDto) {
|
||||
try {
|
||||
return await this.prisma.tag.create({
|
||||
data: {
|
||||
tagName: dto.tagName,
|
||||
tagColor: dto.tagColor ?? null,
|
||||
tagFontColor: dto.tagFontColor ?? null,
|
||||
timing: dto.timing ?? null,
|
||||
tagGroupId: dto.tagGroupId ? BigInt(dto.tagGroupId) : null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateTagDto) {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.TagUpdateInput = {};
|
||||
if (dto.tagName !== undefined) data.tagName = dto.tagName;
|
||||
if (dto.tagColor !== undefined) data.tagColor = dto.tagColor;
|
||||
if (dto.tagFontColor !== undefined) data.tagFontColor = dto.tagFontColor;
|
||||
if (dto.timing !== undefined) data.timing = dto.timing;
|
||||
if (dto.tagGroupId !== undefined) {
|
||||
data.tagGroup = dto.tagGroupId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.tagGroupId) } };
|
||||
}
|
||||
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||
try {
|
||||
return await this.prisma.tag.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.tag.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderTagsDto) {
|
||||
return this.prisma.$transaction(
|
||||
dto.items.map((item) =>
|
||||
this.prisma.tag.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: {
|
||||
sortOrder: item.sortOrder,
|
||||
tagGroupId:
|
||||
item.tagGroupId === undefined
|
||||
? undefined
|
||||
: item.tagGroupId === null
|
||||
? null
|
||||
: BigInt(item.tagGroupId),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
const UPLOAD_DIR = join(process.cwd(), 'uploads');
|
||||
|
||||
// Explicit safe-image whitelist. SVG is deliberately excluded: it can
|
||||
// carry scripts and is served from the same origin (stored XSS).
|
||||
const ALLOWED_EXTENSIONS = /\.(png|jpe?g|webp|gif)$/i;
|
||||
const ALLOWED_MIMETYPES = /^image\/(png|jpe?g|webp|gif)$/i;
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('upload')
|
||||
export class UploadController {
|
||||
@Post('image')
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
destination: UPLOAD_DIR,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = ALLOWED_EXTENSIONS.test(extname(file.originalname))
|
||||
? extname(file.originalname).toLowerCase()
|
||||
: '.png';
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!ALLOWED_EXTENSIONS.test(file.originalname) || !ALLOWED_MIMETYPES.test(file.mimetype)) {
|
||||
return cb(new BadRequestException('仅支持 png/jpg/webp/gif 图片'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
uploadImage(@UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('请选择要上传的文件');
|
||||
}
|
||||
return { url: `/uploads/${file.filename}`, filename: file.filename };
|
||||
}
|
||||
}
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
const UPLOAD_DIR = join(process.cwd(), 'uploads');
|
||||
|
||||
// Explicit safe-image whitelist. SVG is deliberately excluded: it can
|
||||
// carry scripts and is served from the same origin (stored XSS).
|
||||
const ALLOWED_EXTENSIONS = /\.(png|jpe?g|webp|gif)$/i;
|
||||
const ALLOWED_MIMETYPES = /^image\/(png|jpe?g|webp|gif)$/i;
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('upload')
|
||||
export class UploadController {
|
||||
@Post('image')
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
destination: UPLOAD_DIR,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = ALLOWED_EXTENSIONS.test(extname(file.originalname))
|
||||
? extname(file.originalname).toLowerCase()
|
||||
: '.png';
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!ALLOWED_EXTENSIONS.test(file.originalname) || !ALLOWED_MIMETYPES.test(file.mimetype)) {
|
||||
return cb(new BadRequestException('仅支持 png/jpg/webp/gif 图片'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
uploadImage(@UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('请选择要上传的文件');
|
||||
}
|
||||
return { url: `/uploads/${file.filename}`, filename: file.filename };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UploadController } from './upload.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [UploadController],
|
||||
})
|
||||
export class UploadModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UploadController } from './upload.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [UploadController],
|
||||
})
|
||||
export class UploadModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
+23
-23
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user