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:
yeuimu
2026-08-26 14:23:09 +08:00
parent be0b90e68f
commit 6c61a4e871
982 changed files with 74156 additions and 179393 deletions
@@ -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));
}
}
+10 -10
View File
@@ -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 {}
+103 -103
View File
@@ -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);
});
});
+137 -137
View File
@@ -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 })));
}
}
+61 -61
View File
@@ -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));
}
}
+10 -10
View File
@@ -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 {}
+115 -115
View File
@@ -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);
});
});
+100 -100
View File
@@ -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;
}
+60 -60
View File
@@ -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;
}
+30 -30
View File
@@ -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[];
}
+56 -56
View File
@@ -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
View File
@@ -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;
}
+58 -58
View File
@@ -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;
}
+56 -56
View File
@@ -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;
}
+48 -48
View File
@@ -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);
}
+7 -7
View File
@@ -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 {}
+212 -212
View File
@@ -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
View File
@@ -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) {
+7 -3
View File
@@ -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);
});
});
+261 -261
View File
@@ -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;
}
+71 -71
View File
@@ -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));
}
}
+10 -10
View File
@@ -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();
});
});
+93 -93
View File
@@ -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`);
}
}
+14 -14
View File
@@ -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 {}
+31 -31
View File
@@ -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);
});
});
+20 -20
View File
@@ -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();
}
}
+24 -24
View File
@@ -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,
};
+21 -21
View File
@@ -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;
+32 -32
View File
@@ -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;
}
+29 -29
View File
@@ -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,
};
}
}
+39 -39
View File
@@ -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,
};
}
}
+22 -22
View File
@@ -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({
+9 -9
View File
@@ -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 {}
+228 -228
View File
@@ -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);
});
});
+64 -1
View File
@@ -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,
+32 -32
View File
@@ -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,
};
}
}
+33 -33
View File
@@ -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', () => {
+134 -134
View File
@@ -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;
}
+43 -43
View File
@@ -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));
}
}
+14 -14
View File
@@ -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 {}
+269 -269
View File
@@ -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', () => {
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));
}
}
+10 -10
View File
@@ -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 {}
+109 -109
View File
@@ -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 } });
});
});
+88 -88
View File
@@ -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 },
}),
),
);
}
}
+53 -53
View File
@@ -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;
}
+37 -37
View File
@@ -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[];
}
+42 -42
View File
@@ -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;
}
+69 -69
View File
@@ -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));
}
}
+10 -10
View File
@@ -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 {}
+123 -123
View File
@@ -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
View File
@@ -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),
},
}),
),
);
}
}
+54 -54
View File
@@ -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 };
}
}
+7 -7
View File
@@ -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 {}