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:
@@ -1,67 +1,67 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
|
||||
@ApiTags('categories')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly service: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Return categories as a tree' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get('flat')
|
||||
@ApiOperation({ summary: 'Return categories as a flat list' })
|
||||
findFlat() {
|
||||
return this.service.findFlat();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one category' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a category' })
|
||||
create(@Body() dto: CreateCategoryDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a category' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateCategoryDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a category' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
|
||||
@ApiTags('categories')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly service: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Return categories as a tree' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get('flat')
|
||||
@ApiOperation({ summary: 'Return categories as a flat list' })
|
||||
findFlat() {
|
||||
return this.service.findFlat();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one category' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a category' })
|
||||
create(@Body() dto: CreateCategoryDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a category' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateCategoryDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a category' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('CategoriesService', () => {
|
||||
let service: CategoriesService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [CategoriesService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(CategoriesService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.category.deleteMany({
|
||||
where: { categoryName: { in: createdNames } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates root + child + grandchild and assembles them into a tree', async () => {
|
||||
const stamp = Date.now();
|
||||
const rootName = `Root ${stamp}`;
|
||||
const childName = `Child ${stamp}`;
|
||||
const leafName = `Leaf ${stamp}`;
|
||||
createdNames.push(rootName, childName, leafName);
|
||||
|
||||
const root = await service.create({ categoryName: rootName });
|
||||
const child = await service.create({
|
||||
categoryName: childName,
|
||||
parentCategoryId: Number(root.id),
|
||||
});
|
||||
const leaf = await service.create({
|
||||
categoryName: leafName,
|
||||
parentCategoryId: Number(child.id),
|
||||
});
|
||||
|
||||
const tree = await service.findAll();
|
||||
const findNode = (
|
||||
list: Array<{ id: string; children: Array<{ id: string }> }>,
|
||||
id: string,
|
||||
): { id: string; children: Array<{ id: string }> } | undefined => {
|
||||
for (const n of list) {
|
||||
if (n.id === id) return n;
|
||||
const inner = findNode(n.children as any, id);
|
||||
if (inner) return inner;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const rootNode = findNode(tree as any, root.id.toString());
|
||||
expect(rootNode).toBeDefined();
|
||||
const childNode = findNode(rootNode!.children as any, child.id.toString());
|
||||
expect(childNode).toBeDefined();
|
||||
const leafNode = findNode(childNode!.children as any, leaf.id.toString());
|
||||
expect(leafNode).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects deletion when children exist', async () => {
|
||||
const stamp = Date.now();
|
||||
const parent = await service.create({ categoryName: `Parent ${stamp}` });
|
||||
createdNames.push(parent.categoryName);
|
||||
const child = await service.create({
|
||||
categoryName: `Child of ${stamp}`,
|
||||
parentCategoryId: Number(parent.id),
|
||||
});
|
||||
createdNames.push(child.categoryName);
|
||||
|
||||
await expect(service.remove(parent.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a flat list and a single node', async () => {
|
||||
const stamp = Date.now();
|
||||
const c = await service.create({ categoryName: `Flat ${stamp}` });
|
||||
createdNames.push(c.categoryName);
|
||||
const flat = await service.findFlat();
|
||||
expect(flat.some((row) => row.id === c.id)).toBe(true);
|
||||
const single = await service.findOne(c.id);
|
||||
expect(single.categoryName).toBe(c.categoryName);
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('CategoriesService', () => {
|
||||
let service: CategoriesService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [CategoriesService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(CategoriesService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.category.deleteMany({
|
||||
where: { categoryName: { in: createdNames } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates root + child + grandchild and assembles them into a tree', async () => {
|
||||
const stamp = Date.now();
|
||||
const rootName = `Root ${stamp}`;
|
||||
const childName = `Child ${stamp}`;
|
||||
const leafName = `Leaf ${stamp}`;
|
||||
createdNames.push(rootName, childName, leafName);
|
||||
|
||||
const root = await service.create({ categoryName: rootName });
|
||||
const child = await service.create({
|
||||
categoryName: childName,
|
||||
parentCategoryId: Number(root.id),
|
||||
});
|
||||
const leaf = await service.create({
|
||||
categoryName: leafName,
|
||||
parentCategoryId: Number(child.id),
|
||||
});
|
||||
|
||||
const tree = await service.findAll();
|
||||
const findNode = (
|
||||
list: Array<{ id: string; children: Array<{ id: string }> }>,
|
||||
id: string,
|
||||
): { id: string; children: Array<{ id: string }> } | undefined => {
|
||||
for (const n of list) {
|
||||
if (n.id === id) return n;
|
||||
const inner = findNode(n.children as any, id);
|
||||
if (inner) return inner;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const rootNode = findNode(tree as any, root.id.toString());
|
||||
expect(rootNode).toBeDefined();
|
||||
const childNode = findNode(rootNode!.children as any, child.id.toString());
|
||||
expect(childNode).toBeDefined();
|
||||
const leafNode = findNode(childNode!.children as any, leaf.id.toString());
|
||||
expect(leafNode).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects deletion when children exist', async () => {
|
||||
const stamp = Date.now();
|
||||
const parent = await service.create({ categoryName: `Parent ${stamp}` });
|
||||
createdNames.push(parent.categoryName);
|
||||
const child = await service.create({
|
||||
categoryName: `Child of ${stamp}`,
|
||||
parentCategoryId: Number(parent.id),
|
||||
});
|
||||
createdNames.push(child.categoryName);
|
||||
|
||||
await expect(service.remove(parent.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a flat list and a single node', async () => {
|
||||
const stamp = Date.now();
|
||||
const c = await service.create({ categoryName: `Flat ${stamp}` });
|
||||
createdNames.push(c.categoryName);
|
||||
const flat = await service.findFlat();
|
||||
expect(flat.some((row) => row.id === c.id)).toBe(true);
|
||||
const single = await service.findOne(c.id);
|
||||
expect(single.categoryName).toBe(c.categoryName);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,137 +1,137 @@
|
||||
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
import { CategoryNodeDto } from './dto/category-node.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(): Promise<CategoryNodeDto[]> {
|
||||
const all = await this.prisma.category.findMany({
|
||||
orderBy: [{ id: 'asc' }],
|
||||
});
|
||||
return this.buildTree(all);
|
||||
}
|
||||
|
||||
async findFlat() {
|
||||
return this.prisma.category.findMany({
|
||||
orderBy: [{ id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id } });
|
||||
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
||||
return c;
|
||||
}
|
||||
|
||||
async create(dto: CreateCategoryDto) {
|
||||
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||
// Validate the parent exists to produce a clean 404 instead of FK error.
|
||||
await this.findOne(BigInt(dto.parentCategoryId));
|
||||
}
|
||||
return this.prisma.category.create({
|
||||
data: {
|
||||
categoryName: dto.categoryName,
|
||||
categoryIcon: dto.categoryIcon ?? null,
|
||||
parentCategoryId:
|
||||
dto.parentCategoryId === undefined || dto.parentCategoryId === null
|
||||
? null
|
||||
: BigInt(dto.parentCategoryId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateCategoryDto) {
|
||||
await this.findOne(id);
|
||||
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||
// Prevent self-parenting & cycles.
|
||||
if (BigInt(dto.parentCategoryId) === id) {
|
||||
throw new BadRequestException('A category cannot be its own parent');
|
||||
}
|
||||
await this.findOne(BigInt(dto.parentCategoryId));
|
||||
}
|
||||
const data: Prisma.CategoryUpdateInput = {};
|
||||
if (dto.categoryName !== undefined) data.categoryName = dto.categoryName;
|
||||
if (dto.categoryIcon !== undefined) data.categoryIcon = dto.categoryIcon;
|
||||
if (dto.parentCategoryId !== undefined) {
|
||||
data.parent = dto.parentCategoryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.parentCategoryId) } };
|
||||
}
|
||||
return this.prisma.category.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
const childCount = await this.prisma.category.count({
|
||||
where: { parentCategoryId: id },
|
||||
});
|
||||
if (childCount > 0) {
|
||||
throw new BadRequestException(
|
||||
'Category has children and cannot be deleted',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await this.prisma.category.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (this.isForeignKeyViolation(err)) {
|
||||
throw new BadRequestException(
|
||||
'Category is referenced by goods or positions and cannot be deleted',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private isForeignKeyViolation(err: unknown): boolean {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return err.code === 'P2003';
|
||||
}
|
||||
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||
const msg = err.message ?? '';
|
||||
return (
|
||||
msg.includes('foreign key constraint') ||
|
||||
msg.includes('RESTRICT') ||
|
||||
msg.includes('violates')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a tree in-memory from a flat list. Top-level nodes have
|
||||
* `parentCategoryId = null`.
|
||||
*/
|
||||
private buildTree(
|
||||
rows: PrismaCategory[],
|
||||
): CategoryNodeDto[] {
|
||||
const byId = new Map<bigint, CategoryNodeDto>();
|
||||
for (const row of rows) {
|
||||
byId.set(row.id, CategoryNodeDto.from(row, []));
|
||||
}
|
||||
const roots: CategoryNodeDto[] = [];
|
||||
for (const row of rows) {
|
||||
const node = byId.get(row.id)!;
|
||||
if (row.parentCategoryId === null) {
|
||||
roots.push(node);
|
||||
} else {
|
||||
const parent = byId.get(row.parentCategoryId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
// Orphan (parent row missing) — surface as a root.
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
import { CategoryNodeDto } from './dto/category-node.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(): Promise<CategoryNodeDto[]> {
|
||||
const all = await this.prisma.category.findMany({
|
||||
orderBy: [{ id: 'asc' }],
|
||||
});
|
||||
return this.buildTree(all);
|
||||
}
|
||||
|
||||
async findFlat() {
|
||||
return this.prisma.category.findMany({
|
||||
orderBy: [{ id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id } });
|
||||
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
||||
return c;
|
||||
}
|
||||
|
||||
async create(dto: CreateCategoryDto) {
|
||||
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||
// Validate the parent exists to produce a clean 404 instead of FK error.
|
||||
await this.findOne(BigInt(dto.parentCategoryId));
|
||||
}
|
||||
return this.prisma.category.create({
|
||||
data: {
|
||||
categoryName: dto.categoryName,
|
||||
categoryIcon: dto.categoryIcon ?? null,
|
||||
parentCategoryId:
|
||||
dto.parentCategoryId === undefined || dto.parentCategoryId === null
|
||||
? null
|
||||
: BigInt(dto.parentCategoryId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateCategoryDto) {
|
||||
await this.findOne(id);
|
||||
if (dto.parentCategoryId !== undefined && dto.parentCategoryId !== null) {
|
||||
// Prevent self-parenting & cycles.
|
||||
if (BigInt(dto.parentCategoryId) === id) {
|
||||
throw new BadRequestException('A category cannot be its own parent');
|
||||
}
|
||||
await this.findOne(BigInt(dto.parentCategoryId));
|
||||
}
|
||||
const data: Prisma.CategoryUpdateInput = {};
|
||||
if (dto.categoryName !== undefined) data.categoryName = dto.categoryName;
|
||||
if (dto.categoryIcon !== undefined) data.categoryIcon = dto.categoryIcon;
|
||||
if (dto.parentCategoryId !== undefined) {
|
||||
data.parent = dto.parentCategoryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.parentCategoryId) } };
|
||||
}
|
||||
return this.prisma.category.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
const childCount = await this.prisma.category.count({
|
||||
where: { parentCategoryId: id },
|
||||
});
|
||||
if (childCount > 0) {
|
||||
throw new BadRequestException(
|
||||
'Category has children and cannot be deleted',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await this.prisma.category.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (this.isForeignKeyViolation(err)) {
|
||||
throw new BadRequestException(
|
||||
'Category is referenced by goods or positions and cannot be deleted',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private isForeignKeyViolation(err: unknown): boolean {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return err.code === 'P2003';
|
||||
}
|
||||
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||
const msg = err.message ?? '';
|
||||
return (
|
||||
msg.includes('foreign key constraint') ||
|
||||
msg.includes('RESTRICT') ||
|
||||
msg.includes('violates')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a tree in-memory from a flat list. Top-level nodes have
|
||||
* `parentCategoryId = null`.
|
||||
*/
|
||||
private buildTree(
|
||||
rows: PrismaCategory[],
|
||||
): CategoryNodeDto[] {
|
||||
const byId = new Map<bigint, CategoryNodeDto>();
|
||||
for (const row of rows) {
|
||||
byId.set(row.id, CategoryNodeDto.from(row, []));
|
||||
}
|
||||
const roots: CategoryNodeDto[] = [];
|
||||
for (const row of rows) {
|
||||
const node = byId.get(row.id)!;
|
||||
if (row.parentCategoryId === null) {
|
||||
roots.push(node);
|
||||
} else {
|
||||
const parent = byId.get(row.parentCategoryId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
// Orphan (parent row missing) — surface as a root.
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Category as PrismaCategory } from '@prisma/client';
|
||||
|
||||
export class CategoryNodeDto {
|
||||
@ApiProperty({ description: 'Category ID (bigint serialized as string)' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
categoryIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
sdsCategoryId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Parent category ID' })
|
||||
parentCategoryId!: string | null;
|
||||
|
||||
@ApiProperty({ type: [CategoryNodeDto] })
|
||||
children!: CategoryNodeDto[];
|
||||
|
||||
static from(category: PrismaCategory, children: CategoryNodeDto[] = []): CategoryNodeDto {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
categoryName: category.categoryName,
|
||||
categoryIcon: category.categoryIcon,
|
||||
sdsCategoryId: category.sdsCategoryId,
|
||||
parentCategoryId: category.parentCategoryId
|
||||
? category.parentCategoryId.toString()
|
||||
: null,
|
||||
children,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Category as PrismaCategory } from '@prisma/client';
|
||||
|
||||
export class CategoryNodeDto {
|
||||
@ApiProperty({ description: 'Category ID (bigint serialized as string)' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
categoryIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
sdsCategoryId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Parent category ID' })
|
||||
parentCategoryId!: string | null;
|
||||
|
||||
@ApiProperty({ type: [CategoryNodeDto] })
|
||||
children!: CategoryNodeDto[];
|
||||
|
||||
static from(category: PrismaCategory, children: CategoryNodeDto[] = []): CategoryNodeDto {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
categoryName: category.categoryName,
|
||||
categoryIcon: category.categoryIcon,
|
||||
sdsCategoryId: category.sdsCategoryId,
|
||||
parentCategoryId: category.parentCategoryId
|
||||
? category.parentCategoryId.toString()
|
||||
: null,
|
||||
children,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryIcon?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Parent category ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
parentCategoryId?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryIcon?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Parent category ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
parentCategoryId?: number;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateCategoryDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryIcon?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
parentCategoryId?: number | null;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateCategoryDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryIcon?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
parentCategoryId?: number | null;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Pulls the authenticated user out of the request, as populated by
|
||||
* the JWT strategy.
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: keyof { id: bigint; username: string } | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user?: { id: bigint; username: string } }>();
|
||||
const user = request.user;
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Pulls the authenticated user out of the request, as populated by
|
||||
* the JWT strategy.
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: keyof { id: bigint; username: string } | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user?: { id: bigint; username: string } }>();
|
||||
const user = request.user;
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,92 +1,92 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Global HTTP exception filter.
|
||||
*
|
||||
* Normalizes the error envelope to:
|
||||
* ```json
|
||||
* {
|
||||
* "statusCode": 400,
|
||||
* "message": "...",
|
||||
* "error": "...",
|
||||
* "timestamp": "ISO-8601",
|
||||
* "path": "..."
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
// Malformed bigint/number inputs (e.g. `BigInt("abc")`) are client
|
||||
// errors — map them to 400 instead of leaking a 500.
|
||||
if (
|
||||
status === HttpStatus.INTERNAL_SERVER_ERROR &&
|
||||
exception instanceof Error &&
|
||||
/Cannot convert .+ to (a BigInt|number)/i.test(exception.message)
|
||||
) {
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
message: 'Invalid numeric identifier',
|
||||
error: 'BadRequestError',
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let message: string | string[] = 'Internal server error';
|
||||
let error = 'InternalServerError';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const resp = exception.getResponse();
|
||||
if (typeof resp === 'string') {
|
||||
message = resp;
|
||||
} else if (typeof resp === 'object' && resp !== null) {
|
||||
const obj = resp as Record<string, unknown>;
|
||||
message = (obj.message as string | string[]) ?? exception.message;
|
||||
error = (obj.error as string) ?? exception.name;
|
||||
} else {
|
||||
message = exception.message;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
// Unexpected errors (Prisma, driver, ...) may contain SQL or
|
||||
// connection details — never send them to the client.
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} -> ${status} ${exception.message}`,
|
||||
exception.stack,
|
||||
);
|
||||
}
|
||||
|
||||
if (status >= 500 && exception instanceof HttpException) {
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} -> ${status} ${message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Global HTTP exception filter.
|
||||
*
|
||||
* Normalizes the error envelope to:
|
||||
* ```json
|
||||
* {
|
||||
* "statusCode": 400,
|
||||
* "message": "...",
|
||||
* "error": "...",
|
||||
* "timestamp": "ISO-8601",
|
||||
* "path": "..."
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
// Malformed bigint/number inputs (e.g. `BigInt("abc")`) are client
|
||||
// errors — map them to 400 instead of leaking a 500.
|
||||
if (
|
||||
status === HttpStatus.INTERNAL_SERVER_ERROR &&
|
||||
exception instanceof Error &&
|
||||
/Cannot convert .+ to (a BigInt|number)/i.test(exception.message)
|
||||
) {
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
message: 'Invalid numeric identifier',
|
||||
error: 'BadRequestError',
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let message: string | string[] = 'Internal server error';
|
||||
let error = 'InternalServerError';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const resp = exception.getResponse();
|
||||
if (typeof resp === 'string') {
|
||||
message = resp;
|
||||
} else if (typeof resp === 'object' && resp !== null) {
|
||||
const obj = resp as Record<string, unknown>;
|
||||
message = (obj.message as string | string[]) ?? exception.message;
|
||||
error = (obj.error as string) ?? exception.name;
|
||||
} else {
|
||||
message = exception.message;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
// Unexpected errors (Prisma, driver, ...) may contain SQL or
|
||||
// connection details — never send them to the client.
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} -> ${status} ${exception.message}`,
|
||||
exception.stack,
|
||||
);
|
||||
}
|
||||
|
||||
if (status >= 500 && exception instanceof HttpException) {
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} -> ${status} ${message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Wraps every successful response into `{ data, success: true }`.
|
||||
*
|
||||
* Exceptions still flow through the global filter and are not wrapped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(map((data) => ({ data, success: true })));
|
||||
}
|
||||
}
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Wraps every successful response into `{ data, success: true }`.
|
||||
*
|
||||
* Exceptions still flow through the global filter and are not wrapped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransformInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(map((data) => ({ data, success: true })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CountriesService } from './countries.service';
|
||||
import { CreateCountryDto } from './dto/create-country.dto';
|
||||
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||
|
||||
@ApiTags('countries')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('countries')
|
||||
export class CountriesController {
|
||||
constructor(private readonly service: CountriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all countries' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one country' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a country' })
|
||||
create(@Body() dto: CreateCountryDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a country' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateCountryDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a country' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CountriesService } from './countries.service';
|
||||
import { CreateCountryDto } from './dto/create-country.dto';
|
||||
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||
|
||||
@ApiTags('countries')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('countries')
|
||||
export class CountriesController {
|
||||
constructor(private readonly service: CountriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all countries' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one country' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a country' })
|
||||
create(@Body() dto: CreateCountryDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a country' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateCountryDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a country' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CountriesController } from './countries.controller';
|
||||
import { CountriesService } from './countries.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CountriesController],
|
||||
providers: [CountriesService],
|
||||
exports: [CountriesService],
|
||||
})
|
||||
export class CountriesModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CountriesController } from './countries.controller';
|
||||
import { CountriesService } from './countries.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CountriesController],
|
||||
providers: [CountriesService],
|
||||
exports: [CountriesService],
|
||||
})
|
||||
export class CountriesModule {}
|
||||
|
||||
@@ -1,115 +1,115 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CountriesService } from './countries.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('CountriesService', () => {
|
||||
let service: CountriesService;
|
||||
let prisma: PrismaService;
|
||||
const created: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [CountriesService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(CountriesService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (created.length) {
|
||||
// Delete goods/origin_goods first so we can remove the countries.
|
||||
await prisma.good.deleteMany({
|
||||
where: { country: { countryName: { in: created } } },
|
||||
});
|
||||
await prisma.position.deleteMany({
|
||||
where: { country: { countryName: { in: created } } },
|
||||
});
|
||||
await prisma.country.deleteMany({
|
||||
where: { countryName: { in: created } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates and reads back a country', async () => {
|
||||
const name = `Test Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
|
||||
const createdRow = await service.create({ countryName: name });
|
||||
expect(createdRow.countryName).toBe(name);
|
||||
|
||||
const fetched = await service.findOne(createdRow.id);
|
||||
expect(fetched.countryName).toBe(name);
|
||||
});
|
||||
|
||||
it('rejects duplicate names with ConflictException', async () => {
|
||||
const name = `Dup Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
await service.create({ countryName: name });
|
||||
await expect(service.create({ countryName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when deleting a country referenced by goods', async () => {
|
||||
const name = `Ref Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
const country = await service.create({ countryName: name });
|
||||
|
||||
// Need a category + origin good to satisfy the foreign keys before
|
||||
// we can attach a good that references the country.
|
||||
const category = await prisma.category.create({
|
||||
data: { categoryName: `Cat ${Date.now()}` },
|
||||
});
|
||||
const originGood = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `sds-${Date.now()}-${Math.random()}` },
|
||||
});
|
||||
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: originGood.id,
|
||||
countryId: country.id,
|
||||
categoryId: category.id,
|
||||
goodName: 'sample',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.remove(country.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
// cleanup
|
||||
await prisma.good.deleteMany({ where: { countryId: country.id } });
|
||||
await prisma.originGood.delete({ where: { id: originGood.id } });
|
||||
await prisma.category.delete({ where: { id: category.id } });
|
||||
});
|
||||
|
||||
it('updates fields and deletes when unreferenced', async () => {
|
||||
const name = `Upd Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
const c = await service.create({ countryName: name });
|
||||
const updated = await service.update(c.id, { countryName: `${name}-v2` });
|
||||
expect(updated.countryName).toBe(`${name}-v2`);
|
||||
created[created.indexOf(name)] = `${name}-v2`;
|
||||
|
||||
await service.remove(c.id);
|
||||
const idx = created.indexOf(`${name}-v2`);
|
||||
if (idx !== -1) created.splice(idx, 1);
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CountriesService } from './countries.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('CountriesService', () => {
|
||||
let service: CountriesService;
|
||||
let prisma: PrismaService;
|
||||
const created: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [CountriesService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(CountriesService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (created.length) {
|
||||
// Delete goods/origin_goods first so we can remove the countries.
|
||||
await prisma.good.deleteMany({
|
||||
where: { country: { countryName: { in: created } } },
|
||||
});
|
||||
await prisma.position.deleteMany({
|
||||
where: { country: { countryName: { in: created } } },
|
||||
});
|
||||
await prisma.country.deleteMany({
|
||||
where: { countryName: { in: created } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates and reads back a country', async () => {
|
||||
const name = `Test Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
|
||||
const createdRow = await service.create({ countryName: name });
|
||||
expect(createdRow.countryName).toBe(name);
|
||||
|
||||
const fetched = await service.findOne(createdRow.id);
|
||||
expect(fetched.countryName).toBe(name);
|
||||
});
|
||||
|
||||
it('rejects duplicate names with ConflictException', async () => {
|
||||
const name = `Dup Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
await service.create({ countryName: name });
|
||||
await expect(service.create({ countryName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when deleting a country referenced by goods', async () => {
|
||||
const name = `Ref Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
const country = await service.create({ countryName: name });
|
||||
|
||||
// Need a category + origin good to satisfy the foreign keys before
|
||||
// we can attach a good that references the country.
|
||||
const category = await prisma.category.create({
|
||||
data: { categoryName: `Cat ${Date.now()}` },
|
||||
});
|
||||
const originGood = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `sds-${Date.now()}-${Math.random()}` },
|
||||
});
|
||||
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
originGoodId: originGood.id,
|
||||
countryId: country.id,
|
||||
categoryId: category.id,
|
||||
goodName: 'sample',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.remove(country.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
// cleanup
|
||||
await prisma.good.deleteMany({ where: { countryId: country.id } });
|
||||
await prisma.originGood.delete({ where: { id: originGood.id } });
|
||||
await prisma.category.delete({ where: { id: category.id } });
|
||||
});
|
||||
|
||||
it('updates fields and deletes when unreferenced', async () => {
|
||||
const name = `Upd Country ${Date.now()}`;
|
||||
created.push(name);
|
||||
const c = await service.create({ countryName: name });
|
||||
const updated = await service.update(c.id, { countryName: `${name}-v2` });
|
||||
expect(updated.countryName).toBe(`${name}-v2`);
|
||||
created[created.indexOf(name)] = `${name}-v2`;
|
||||
|
||||
await service.remove(c.id);
|
||||
const idx = created.indexOf(`${name}-v2`);
|
||||
if (idx !== -1) created.splice(idx, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCountryDto } from './dto/create-country.dto';
|
||||
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CountriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.country.findMany({ orderBy: { id: 'asc' } });
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const country = await this.prisma.country.findUnique({ where: { id } });
|
||||
if (!country) {
|
||||
throw new NotFoundException(`Country ${id} not found`);
|
||||
}
|
||||
return country;
|
||||
}
|
||||
|
||||
async create(dto: CreateCountryDto) {
|
||||
try {
|
||||
return await this.prisma.country.create({
|
||||
data: {
|
||||
countryName: dto.countryName,
|
||||
countryIcon: dto.countryIcon ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Country name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateCountryDto) {
|
||||
await this.findOne(id);
|
||||
try {
|
||||
return await this.prisma.country.update({
|
||||
where: { id },
|
||||
data: {
|
||||
countryName: dto.countryName,
|
||||
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Country name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
try {
|
||||
return await this.prisma.country.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (this.isForeignKeyViolation(err)) {
|
||||
throw new BadRequestException(
|
||||
'Country is referenced by goods or positions and cannot be deleted',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private isForeignKeyViolation(err: unknown): boolean {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
// P2003 = FK constraint violation
|
||||
return err.code === 'P2003';
|
||||
}
|
||||
// Fallback: Prisma sometimes surfaces FK violations as UnknownRequestError
|
||||
// when the constraint check happens server-side before the typed error
|
||||
// is mapped (e.g. cascading RESTRICT).
|
||||
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||
const msg = err.message ?? '';
|
||||
return (
|
||||
msg.includes('foreign key constraint') ||
|
||||
msg.includes('RESTRICT') ||
|
||||
msg.includes('violates')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCountryDto } from './dto/create-country.dto';
|
||||
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CountriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.country.findMany({ orderBy: { id: 'asc' } });
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const country = await this.prisma.country.findUnique({ where: { id } });
|
||||
if (!country) {
|
||||
throw new NotFoundException(`Country ${id} not found`);
|
||||
}
|
||||
return country;
|
||||
}
|
||||
|
||||
async create(dto: CreateCountryDto) {
|
||||
try {
|
||||
return await this.prisma.country.create({
|
||||
data: {
|
||||
countryName: dto.countryName,
|
||||
countryIcon: dto.countryIcon ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Country name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateCountryDto) {
|
||||
await this.findOne(id);
|
||||
try {
|
||||
return await this.prisma.country.update({
|
||||
where: { id },
|
||||
data: {
|
||||
countryName: dto.countryName,
|
||||
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Country name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
try {
|
||||
return await this.prisma.country.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (this.isForeignKeyViolation(err)) {
|
||||
throw new BadRequestException(
|
||||
'Country is referenced by goods or positions and cannot be deleted',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private isForeignKeyViolation(err: unknown): boolean {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
// P2003 = FK constraint violation
|
||||
return err.code === 'P2003';
|
||||
}
|
||||
// Fallback: Prisma sometimes surfaces FK violations as UnknownRequestError
|
||||
// when the constraint check happens server-side before the typed error
|
||||
// is mapped (e.g. cascading RESTRICT).
|
||||
if (err instanceof Prisma.PrismaClientUnknownRequestError) {
|
||||
const msg = err.message ?? '';
|
||||
return (
|
||||
msg.includes('foreign key constraint') ||
|
||||
msg.includes('RESTRICT') ||
|
||||
msg.includes('violates')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateCountryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
countryName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryIcon?: string;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateCountryDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
countryName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryIcon?: string;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateCountryDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryIcon?: string | null;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateCountryDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryIcon?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class BatchCreateItemDto {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId!: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export class BatchCreateGoodDto {
|
||||
@ApiProperty({ type: [BatchCreateItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BatchCreateItemDto)
|
||||
items!: BatchCreateItemDto[];
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
defaultPriority?: number;
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class BatchCreateItemDto {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId!: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export class BatchCreateGoodDto {
|
||||
@ApiProperty({ type: [BatchCreateItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BatchCreateItemDto)
|
||||
items!: BatchCreateItemDto[];
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
defaultPriority?: number;
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class PriorityItemDto {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
priority!: number;
|
||||
}
|
||||
|
||||
export class BatchPriorityDto {
|
||||
@ApiProperty({ type: [PriorityItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PriorityItemDto)
|
||||
items!: PriorityItemDto[];
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class PriorityItemDto {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
priority!: number;
|
||||
}
|
||||
|
||||
export class BatchPriorityDto {
|
||||
@ApiProperty({ type: [PriorityItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PriorityItemDto)
|
||||
items!: PriorityItemDto[];
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateGoodDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
goodPriority?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodImage?: string;
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateGoodDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
goodPriority?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodImage?: string;
|
||||
}
|
||||
+136
-136
@@ -1,17 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Good as PrismaGood } from '@prisma/client';
|
||||
|
||||
export interface GoodRelations {
|
||||
country?: { id: bigint; countryName: string; countryIcon: string | null } | null;
|
||||
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
|
||||
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
position?: { id: bigint; indexVal: number } | null;
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Good as PrismaGood } from '@prisma/client';
|
||||
|
||||
export interface GoodRelations {
|
||||
country?: { id: bigint; countryName: string; countryIcon: string | null } | null;
|
||||
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
|
||||
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
position?: { id: bigint; indexVal: number } | null;
|
||||
originGood?: {
|
||||
id: bigint;
|
||||
id: bigint;
|
||||
sdsGoodId: string;
|
||||
source: 'SDS' | 'CUSTOM';
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: unknown;
|
||||
detail?: {
|
||||
productCode: string | null;
|
||||
@@ -31,67 +31,67 @@ export interface GoodRelations {
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
_count?: { variants: number };
|
||||
} | null;
|
||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||
}
|
||||
|
||||
export class GoodDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
goodImage!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
goodPriority!: number;
|
||||
|
||||
@ApiProperty()
|
||||
countryId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryId!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
positionId!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
originGoodId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
country?: { id: string; countryName: string; countryIcon: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
category?: { id: string; categoryName: string; categoryIcon: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
tag?: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
position?: { id: string; indexVal: number } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
} | null;
|
||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||
}
|
||||
|
||||
export class GoodDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
goodImage!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
goodPriority!: number;
|
||||
|
||||
@ApiProperty()
|
||||
countryId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryId!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
positionId!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
originGoodId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
country?: { id: string; countryName: string; countryIcon: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
category?: { id: string; categoryName: string; categoryIcon: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
tag?: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||
|
||||
@ApiProperty({ required: false, type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
position?: { id: string; indexVal: number } | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
originGood?: {
|
||||
id: string;
|
||||
id: string;
|
||||
sdsGoodId: string;
|
||||
source: 'SDS' | 'CUSTOM';
|
||||
isCustom: boolean;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
hasDetail: boolean;
|
||||
detailSyncedAt: string | null;
|
||||
@@ -99,72 +99,72 @@ export class GoodDto {
|
||||
sizeRowCount: number;
|
||||
packageRowCount: number;
|
||||
productCode: string | null;
|
||||
} | null;
|
||||
|
||||
static from(
|
||||
good: PrismaGood,
|
||||
rel: GoodRelations = {},
|
||||
): GoodDto {
|
||||
return {
|
||||
id: good.id.toString(),
|
||||
goodName: good.goodName,
|
||||
goodImage: good.goodImage,
|
||||
goodPriority: good.goodPriority,
|
||||
countryId: good.countryId.toString(),
|
||||
categoryId: good.categoryId.toString(),
|
||||
tagId: good.tagId === null || good.tagId === undefined ? null : good.tagId.toString(),
|
||||
positionId: good.positionId === null || good.positionId === undefined ? null : good.positionId.toString(),
|
||||
originGoodId: good.originGoodId.toString(),
|
||||
createdAt: good.createdAt.toISOString(),
|
||||
updatedAt: good.updatedAt.toISOString(),
|
||||
country: rel.country
|
||||
? {
|
||||
id: rel.country.id.toString(),
|
||||
countryName: rel.country.countryName,
|
||||
countryIcon: rel.country.countryIcon,
|
||||
}
|
||||
: null,
|
||||
category: rel.category
|
||||
? {
|
||||
id: rel.category.id.toString(),
|
||||
categoryName: rel.category.categoryName,
|
||||
categoryIcon: rel.category.categoryIcon,
|
||||
}
|
||||
: null,
|
||||
tag: rel.tag
|
||||
? {
|
||||
id: rel.tag.id.toString(),
|
||||
tagName: rel.tag.tagName,
|
||||
tagColor: rel.tag.tagColor,
|
||||
tagFontColor: rel.tag.tagFontColor,
|
||||
}
|
||||
: null,
|
||||
tags: rel.goodTags
|
||||
? rel.goodTags.map((gt) => ({
|
||||
id: gt.tag.id.toString(),
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
}))
|
||||
: [],
|
||||
position: rel.position
|
||||
? {
|
||||
id: rel.position.id.toString(),
|
||||
indexVal: rel.position.indexVal,
|
||||
}
|
||||
: null,
|
||||
} | null;
|
||||
|
||||
static from(
|
||||
good: PrismaGood,
|
||||
rel: GoodRelations = {},
|
||||
): GoodDto {
|
||||
return {
|
||||
id: good.id.toString(),
|
||||
goodName: good.goodName,
|
||||
goodImage: good.goodImage,
|
||||
goodPriority: good.goodPriority,
|
||||
countryId: good.countryId.toString(),
|
||||
categoryId: good.categoryId.toString(),
|
||||
tagId: good.tagId === null || good.tagId === undefined ? null : good.tagId.toString(),
|
||||
positionId: good.positionId === null || good.positionId === undefined ? null : good.positionId.toString(),
|
||||
originGoodId: good.originGoodId.toString(),
|
||||
createdAt: good.createdAt.toISOString(),
|
||||
updatedAt: good.updatedAt.toISOString(),
|
||||
country: rel.country
|
||||
? {
|
||||
id: rel.country.id.toString(),
|
||||
countryName: rel.country.countryName,
|
||||
countryIcon: rel.country.countryIcon,
|
||||
}
|
||||
: null,
|
||||
category: rel.category
|
||||
? {
|
||||
id: rel.category.id.toString(),
|
||||
categoryName: rel.category.categoryName,
|
||||
categoryIcon: rel.category.categoryIcon,
|
||||
}
|
||||
: null,
|
||||
tag: rel.tag
|
||||
? {
|
||||
id: rel.tag.id.toString(),
|
||||
tagName: rel.tag.tagName,
|
||||
tagColor: rel.tag.tagColor,
|
||||
tagFontColor: rel.tag.tagFontColor,
|
||||
}
|
||||
: null,
|
||||
tags: rel.goodTags
|
||||
? rel.goodTags.map((gt) => ({
|
||||
id: gt.tag.id.toString(),
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
}))
|
||||
: [],
|
||||
position: rel.position
|
||||
? {
|
||||
id: rel.position.id.toString(),
|
||||
indexVal: rel.position.indexVal,
|
||||
}
|
||||
: null,
|
||||
originGood: rel.originGood
|
||||
? {
|
||||
id: rel.originGood.id.toString(),
|
||||
id: rel.originGood.id.toString(),
|
||||
sdsGoodId: rel.originGood.sdsGoodId,
|
||||
source: rel.originGood.source,
|
||||
isCustom: rel.originGood.source === 'CUSTOM',
|
||||
goodName: rel.originGood.goodName,
|
||||
goodImage: rel.originGood.goodImage,
|
||||
goodPrice:
|
||||
rel.originGood.goodPrice === null ||
|
||||
rel.originGood.goodPrice === undefined
|
||||
? null
|
||||
goodName: rel.originGood.goodName,
|
||||
goodImage: rel.originGood.goodImage,
|
||||
goodPrice:
|
||||
rel.originGood.goodPrice === null ||
|
||||
rel.originGood.goodPrice === undefined
|
||||
? null
|
||||
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
||||
hasDetail: Boolean(rel.originGood.detail),
|
||||
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
|
||||
@@ -173,7 +173,7 @@ export class GoodDto {
|
||||
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
||||
productCode: rel.originGood.detail?.productCode ?? null,
|
||||
}
|
||||
: null,
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,10 +207,10 @@ export class GoodDetailDto extends GoodDto {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaginatedGoods {
|
||||
items: GoodDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
|
||||
export interface PaginatedGoods {
|
||||
items: GoodDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class QueryGoodDto {
|
||||
@ApiProperty({ required: false, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiProperty({ required: false, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 20;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Includes all descendants of this category recursively',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
tagId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class QueryGoodDto {
|
||||
@ApiProperty({ required: false, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiProperty({ required: false, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 20;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Includes all descendants of this category recursively',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
tagId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
positionId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateGoodDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodName?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
goodPriority?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodImage?: string | null;
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateGoodDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodName?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
originGoodId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
tagIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
positionId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
goodPriority?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodImage?: string | null;
|
||||
}
|
||||
@@ -1,59 +1,59 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { CreateGoodDto } from './dto/create-good.dto';
|
||||
import { UpdateGoodDto } from './dto/update-good.dto';
|
||||
import { QueryGoodDto } from './dto/query-good.dto';
|
||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { CreateGoodDto } from './dto/create-good.dto';
|
||||
import { UpdateGoodDto } from './dto/update-good.dto';
|
||||
import { QueryGoodDto } from './dto/query-good.dto';
|
||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||
import {
|
||||
CreateCustomGoodDto,
|
||||
UpdateCustomGoodContentDto,
|
||||
} from './dto/custom-good.dto';
|
||||
|
||||
@ApiTags('goods')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('goods')
|
||||
export class GoodsController {
|
||||
constructor(private readonly service: GoodsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List goods with filters & pagination' })
|
||||
findAll(@Query() query: QueryGoodDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
@ApiTags('goods')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('goods')
|
||||
export class GoodsController {
|
||||
constructor(private readonly service: GoodsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List goods with filters & pagination' })
|
||||
findAll(@Query() query: QueryGoodDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a good' })
|
||||
create(@Body() dto: CreateGoodDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Create a good' })
|
||||
create(@Body() dto: CreateGoodDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch('batch-priority')
|
||||
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
|
||||
batchPriority(@Body() dto: BatchPriorityDto) {
|
||||
return this.service.batchUpdatePriority(dto);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
|
||||
batchPriority(@Body() dto: BatchPriorityDto) {
|
||||
return this.service.batchUpdatePriority(dto);
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
|
||||
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
|
||||
batchCreate(@Body() dto: BatchCreateGoodDto) {
|
||||
return this.service.batchCreate(dto);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GoodsController } from './goods.controller';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GoodsController } from './goods.controller';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { SyncModule } from '../sync/sync.module';
|
||||
|
||||
@Module({
|
||||
imports: [SyncModule],
|
||||
controllers: [GoodsController],
|
||||
providers: [GoodsService],
|
||||
exports: [GoodsService],
|
||||
})
|
||||
export class GoodsModule {}
|
||||
controllers: [GoodsController],
|
||||
providers: [GoodsService],
|
||||
exports: [GoodsService],
|
||||
})
|
||||
export class GoodsModule {}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
describe('GoodsService', () => {
|
||||
let service: GoodsService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
|
||||
// Fixtures
|
||||
let countryId: bigint;
|
||||
let country2Id: bigint;
|
||||
let categoryId: bigint;
|
||||
let childCategoryId: bigint;
|
||||
let tagId: bigint;
|
||||
let positionId: bigint;
|
||||
let originGoodIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
|
||||
describe('GoodsService', () => {
|
||||
let service: GoodsService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
|
||||
// Fixtures
|
||||
let countryId: bigint;
|
||||
let country2Id: bigint;
|
||||
let categoryId: bigint;
|
||||
let childCategoryId: bigint;
|
||||
let tagId: bigint;
|
||||
let positionId: bigint;
|
||||
let originGoodIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
GoodsService,
|
||||
PrismaService,
|
||||
@@ -31,92 +31,92 @@ describe('GoodsService', () => {
|
||||
useValue: { queueProductDetailSync: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(GoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const country = await prisma.country.create({
|
||||
data: { countryName: `Goods Country ${stamp}` },
|
||||
});
|
||||
countryId = country.id;
|
||||
const country2 = await prisma.country.create({
|
||||
data: { countryName: `Goods Country 2 ${stamp}` },
|
||||
});
|
||||
country2Id = country2.id;
|
||||
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Goods Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
const child = await prisma.category.create({
|
||||
data: { categoryName: `Goods Child ${stamp}`, parentCategoryId: cat.id },
|
||||
});
|
||||
childCategoryId = child.id;
|
||||
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName: `Goods Tag ${stamp}`, tagColor: '#00FF00' },
|
||||
});
|
||||
tagId = tag.id;
|
||||
|
||||
const pos = await prisma.position.create({
|
||||
data: { indexVal: 1, countryId, categoryId },
|
||||
});
|
||||
positionId = pos.id;
|
||||
|
||||
const originGoods = await Promise.all(
|
||||
Array.from({ length: 5 }).map((_, i) =>
|
||||
prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `sds-goods-${stamp}-${i}`,
|
||||
goodName: `Goods Origin ${stamp} ${i}`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
originGoodIds = originGoods.map((og) => og.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Wipe all goods first so origin_goods/category can be removed.
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Goods Test ${stamp}` } },
|
||||
});
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Origin ${stamp}` } },
|
||||
});
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { id: { in: originGoodIds } },
|
||||
});
|
||||
await prisma.position.delete({ where: { id: positionId } });
|
||||
await prisma.tag.delete({ where: { id: tagId } });
|
||||
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.country.delete({ where: { id: country2Id } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
}).compile();
|
||||
service = moduleRef.get(GoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const country = await prisma.country.create({
|
||||
data: { countryName: `Goods Country ${stamp}` },
|
||||
});
|
||||
countryId = country.id;
|
||||
const country2 = await prisma.country.create({
|
||||
data: { countryName: `Goods Country 2 ${stamp}` },
|
||||
});
|
||||
country2Id = country2.id;
|
||||
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Goods Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
const child = await prisma.category.create({
|
||||
data: { categoryName: `Goods Child ${stamp}`, parentCategoryId: cat.id },
|
||||
});
|
||||
childCategoryId = child.id;
|
||||
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName: `Goods Tag ${stamp}`, tagColor: '#00FF00' },
|
||||
});
|
||||
tagId = tag.id;
|
||||
|
||||
const pos = await prisma.position.create({
|
||||
data: { indexVal: 1, countryId, categoryId },
|
||||
});
|
||||
positionId = pos.id;
|
||||
|
||||
const originGoods = await Promise.all(
|
||||
Array.from({ length: 5 }).map((_, i) =>
|
||||
prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `sds-goods-${stamp}-${i}`,
|
||||
goodName: `Goods Origin ${stamp} ${i}`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
originGoodIds = originGoods.map((og) => og.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Wipe all goods first so origin_goods/category can be removed.
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Goods Test ${stamp}` } },
|
||||
});
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Origin ${stamp}` } },
|
||||
});
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { id: { in: originGoodIds } },
|
||||
});
|
||||
await prisma.position.delete({ where: { id: positionId } });
|
||||
await prisma.tag.delete({ where: { id: tagId } });
|
||||
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.country.delete({ where: { id: country2Id } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates and reads back a good', async () => {
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} basic`,
|
||||
originGoodId: Number(originGoodIds[0]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
tagIds: [Number(tagId)],
|
||||
positionId: Number(positionId),
|
||||
goodPriority: 3,
|
||||
});
|
||||
expect(created.id).toBeTruthy();
|
||||
expect(created.country?.countryName).toBeTruthy();
|
||||
expect(created.tags.some((t) => t.tagColor === '#00FF00')).toBe(true);
|
||||
|
||||
const fetched = await service.findOne(BigInt(created.id));
|
||||
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} basic`,
|
||||
originGoodId: Number(originGoodIds[0]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
tagIds: [Number(tagId)],
|
||||
positionId: Number(positionId),
|
||||
goodPriority: 3,
|
||||
});
|
||||
expect(created.id).toBeTruthy();
|
||||
expect(created.country?.countryName).toBeTruthy();
|
||||
expect(created.tags.some((t) => t.tagColor === '#00FF00')).toBe(true);
|
||||
|
||||
const fetched = await service.findOne(BigInt(created.id));
|
||||
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
||||
});
|
||||
|
||||
it('creates, edits, and removes a fully editable custom good', async () => {
|
||||
@@ -163,107 +163,107 @@ describe('GoodsService', () => {
|
||||
prisma.originGood.findUnique({ where: { id: customOriginId } }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('filters by countryId, tagId, positionId and keyword', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
countryId: Number(countryId),
|
||||
tagId: Number(tagId),
|
||||
keyword: `Goods Test ${stamp}`,
|
||||
});
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true);
|
||||
expect(
|
||||
result.items.every((g) => g.tags.some((t) => t.id === tagId.toString())),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('categoryId filter includes descendants recursively', async () => {
|
||||
const inChild = await service.create({
|
||||
goodName: `Goods Test ${stamp} child`,
|
||||
originGoodId: Number(originGoodIds[1]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(childCategoryId),
|
||||
});
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
categoryId: Number(categoryId),
|
||||
keyword: `Goods Test ${stamp}`,
|
||||
});
|
||||
const ids = result.items.map((g) => g.id);
|
||||
expect(ids).toContain(inChild.id);
|
||||
});
|
||||
|
||||
it('batch update priority is atomic', async () => {
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} prio`,
|
||||
originGoodId: Number(originGoodIds[2]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
const result = await service.batchUpdatePriority({
|
||||
items: [{ id: Number(created.id), priority: 42 }],
|
||||
});
|
||||
expect(result.count).toBe(1);
|
||||
const after = await service.findOne(BigInt(created.id));
|
||||
expect(after.goodPriority).toBe(42);
|
||||
});
|
||||
|
||||
it('batch create creates all rows or none', async () => {
|
||||
// Count of goods whose originGoodId is one of the two fixture ids,
|
||||
// so we are independent of goodName (which batch derives from origin).
|
||||
const before = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
const created = await service.batchCreate({
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
defaultPriority: 1,
|
||||
items: [
|
||||
{ originGoodId: Number(originGoodIds[3]) },
|
||||
{ originGoodId: Number(originGoodIds[4]) },
|
||||
],
|
||||
});
|
||||
expect(created.length).toBe(2);
|
||||
const after = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
expect(after.total).toBe(before.total + 2);
|
||||
});
|
||||
|
||||
it('batch create rolls back on failure', async () => {
|
||||
const before = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
await expect(
|
||||
service.batchCreate({
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
items: [
|
||||
{ originGoodId: Number(originGoodIds[0]) },
|
||||
{ originGoodId: 99999999 }, // missing -> failure
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const after = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
expect(after.total).toBe(before.total);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by countryId, tagId, positionId and keyword', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
countryId: Number(countryId),
|
||||
tagId: Number(tagId),
|
||||
keyword: `Goods Test ${stamp}`,
|
||||
});
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true);
|
||||
expect(
|
||||
result.items.every((g) => g.tags.some((t) => t.id === tagId.toString())),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('categoryId filter includes descendants recursively', async () => {
|
||||
const inChild = await service.create({
|
||||
goodName: `Goods Test ${stamp} child`,
|
||||
originGoodId: Number(originGoodIds[1]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(childCategoryId),
|
||||
});
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
categoryId: Number(categoryId),
|
||||
keyword: `Goods Test ${stamp}`,
|
||||
});
|
||||
const ids = result.items.map((g) => g.id);
|
||||
expect(ids).toContain(inChild.id);
|
||||
});
|
||||
|
||||
it('batch update priority is atomic', async () => {
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} prio`,
|
||||
originGoodId: Number(originGoodIds[2]),
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
const result = await service.batchUpdatePriority({
|
||||
items: [{ id: Number(created.id), priority: 42 }],
|
||||
});
|
||||
expect(result.count).toBe(1);
|
||||
const after = await service.findOne(BigInt(created.id));
|
||||
expect(after.goodPriority).toBe(42);
|
||||
});
|
||||
|
||||
it('batch create creates all rows or none', async () => {
|
||||
// Count of goods whose originGoodId is one of the two fixture ids,
|
||||
// so we are independent of goodName (which batch derives from origin).
|
||||
const before = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
const created = await service.batchCreate({
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
defaultPriority: 1,
|
||||
items: [
|
||||
{ originGoodId: Number(originGoodIds[3]) },
|
||||
{ originGoodId: Number(originGoodIds[4]) },
|
||||
],
|
||||
});
|
||||
expect(created.length).toBe(2);
|
||||
const after = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
expect(after.total).toBe(before.total + 2);
|
||||
});
|
||||
|
||||
it('batch create rolls back on failure', async () => {
|
||||
const before = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
await expect(
|
||||
service.batchCreate({
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
items: [
|
||||
{ originGoodId: Number(originGoodIds[0]) },
|
||||
{ originGoodId: 99999999 }, // missing -> failure
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const after = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: `Goods Origin ${stamp}`,
|
||||
});
|
||||
expect(after.total).toBe(before.total);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+286
-286
@@ -1,14 +1,14 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateGoodDto } from './dto/create-good.dto';
|
||||
import { UpdateGoodDto } from './dto/update-good.dto';
|
||||
import { QueryGoodDto } from './dto/query-good.dto';
|
||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateGoodDto } from './dto/create-good.dto';
|
||||
import { UpdateGoodDto } from './dto/update-good.dto';
|
||||
import { QueryGoodDto } from './dto/query-good.dto';
|
||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
CustomGoodVariantDto,
|
||||
UpdateCustomGoodContentDto,
|
||||
} from './dto/custom-good.dto';
|
||||
|
||||
const GOOD_INCLUDE = {
|
||||
country: true,
|
||||
category: true,
|
||||
tag: true,
|
||||
position: true,
|
||||
|
||||
const GOOD_INCLUDE = {
|
||||
country: true,
|
||||
category: true,
|
||||
tag: true,
|
||||
position: true,
|
||||
originGood: {
|
||||
include: {
|
||||
detail: true,
|
||||
@@ -32,105 +32,105 @@ const GOOD_INCLUDE = {
|
||||
_count: { select: { variants: true } },
|
||||
},
|
||||
},
|
||||
goodTags: { include: { tag: true } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
@Injectable()
|
||||
export class GoodsService {
|
||||
goodTags: { include: { tag: true } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
@Injectable()
|
||||
export class GoodsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly syncService: SyncService,
|
||||
) {}
|
||||
|
||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
||||
const where: Prisma.GoodWhereInput = {};
|
||||
if (countryId !== undefined) where.countryId = BigInt(countryId);
|
||||
if (tagId !== undefined) where.goodTags = { some: { tagId: BigInt(tagId) } };
|
||||
if (positionId !== undefined) where.positionId = BigInt(positionId);
|
||||
if (keyword) {
|
||||
where.goodName = { contains: keyword, mode: 'insensitive' };
|
||||
}
|
||||
if (categoryId !== undefined) {
|
||||
const ids = await this.collectCategoryDescendants(BigInt(categoryId));
|
||||
where.categoryId = { in: ids };
|
||||
}
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.good.count({ where }),
|
||||
this.prisma.good.findMany({
|
||||
where,
|
||||
include: GOOD_INCLUDE,
|
||||
orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((g) => GoodDto.from(g, {
|
||||
country: g.country,
|
||||
category: g.category,
|
||||
tag: g.tag,
|
||||
position: g.position,
|
||||
originGood: g.originGood,
|
||||
goodTags: g.goodTags,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
||||
const where: Prisma.GoodWhereInput = {};
|
||||
if (countryId !== undefined) where.countryId = BigInt(countryId);
|
||||
if (tagId !== undefined) where.goodTags = { some: { tagId: BigInt(tagId) } };
|
||||
if (positionId !== undefined) where.positionId = BigInt(positionId);
|
||||
if (keyword) {
|
||||
where.goodName = { contains: keyword, mode: 'insensitive' };
|
||||
}
|
||||
if (categoryId !== undefined) {
|
||||
const ids = await this.collectCategoryDescendants(BigInt(categoryId));
|
||||
where.categoryId = { in: ids };
|
||||
}
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.good.count({ where }),
|
||||
this.prisma.good.findMany({
|
||||
where,
|
||||
include: GOOD_INCLUDE,
|
||||
orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((g) => GoodDto.from(g, {
|
||||
country: g.country,
|
||||
category: g.category,
|
||||
tag: g.tag,
|
||||
position: g.position,
|
||||
originGood: g.originGood,
|
||||
goodTags: g.goodTags,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: bigint): Promise<GoodDetailDto> {
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
return GoodDetailDto.fromGood(good, {
|
||||
country: good.country,
|
||||
category: good.category,
|
||||
tag: good.tag,
|
||||
position: good.position,
|
||||
originGood: good.originGood,
|
||||
goodTags: good.goodTags,
|
||||
});
|
||||
}
|
||||
|
||||
country: good.country,
|
||||
category: good.category,
|
||||
tag: good.tag,
|
||||
position: good.position,
|
||||
originGood: good.originGood,
|
||||
goodTags: good.goodTags,
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||
await this.ensureReferences(dto);
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.good.create({
|
||||
data: {
|
||||
goodName: dto.goodName,
|
||||
goodImage: dto.goodImage,
|
||||
originGoodId: BigInt(dto.originGoodId),
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodPriority: dto.goodPriority ?? 0,
|
||||
},
|
||||
});
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: created.id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
const created = await tx.good.create({
|
||||
data: {
|
||||
goodName: dto.goodName,
|
||||
goodImage: dto.goodImage,
|
||||
originGoodId: BigInt(dto.originGoodId),
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodPriority: dto.goodPriority ?? 0,
|
||||
},
|
||||
});
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: created.id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
return GoodDto.from(result, {
|
||||
country: result.country,
|
||||
category: result.category,
|
||||
tag: result.tag,
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
country: result.country,
|
||||
category: result.category,
|
||||
tag: result.tag,
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
});
|
||||
});
|
||||
if (
|
||||
@@ -243,61 +243,61 @@ export class GoodsService {
|
||||
});
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.GoodUpdateInput = {};
|
||||
if (dto.goodName !== undefined) data.goodName = dto.goodName;
|
||||
if (dto.originGoodId !== undefined) {
|
||||
await this.ensureOriginGood(dto.originGoodId);
|
||||
data.originGood = { connect: { id: BigInt(dto.originGoodId) } };
|
||||
}
|
||||
if (dto.countryId !== undefined) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
data.country = { connect: { id: BigInt(dto.countryId) } };
|
||||
}
|
||||
if (dto.categoryId !== undefined) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
data.category = { connect: { id: BigInt(dto.categoryId) } };
|
||||
}
|
||||
if (dto.positionId !== undefined) {
|
||||
data.position =
|
||||
dto.positionId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.positionId) } };
|
||||
}
|
||||
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
|
||||
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
|
||||
if (dto.tagIds !== undefined) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.GoodUpdateInput = {};
|
||||
if (dto.goodName !== undefined) data.goodName = dto.goodName;
|
||||
if (dto.originGoodId !== undefined) {
|
||||
await this.ensureOriginGood(dto.originGoodId);
|
||||
data.originGood = { connect: { id: BigInt(dto.originGoodId) } };
|
||||
}
|
||||
if (dto.countryId !== undefined) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
data.country = { connect: { id: BigInt(dto.countryId) } };
|
||||
}
|
||||
if (dto.categoryId !== undefined) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
data.category = { connect: { id: BigInt(dto.categoryId) } };
|
||||
}
|
||||
if (dto.positionId !== undefined) {
|
||||
data.position =
|
||||
dto.positionId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.positionId) } };
|
||||
}
|
||||
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
|
||||
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
|
||||
if (dto.tagIds !== undefined) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
if (dto.tagIds !== undefined) {
|
||||
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
||||
if (dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
const updated = await tx.good.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
if (dto.tagIds !== undefined) {
|
||||
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
||||
if (dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
const updated = await tx.good.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
return GoodDto.from(updated, {
|
||||
country: updated.country,
|
||||
category: updated.category,
|
||||
tag: updated.tag,
|
||||
position: updated.position,
|
||||
originGood: updated.originGood,
|
||||
goodTags: updated.goodTags,
|
||||
country: updated.country,
|
||||
category: updated.category,
|
||||
tag: updated.tag,
|
||||
position: updated.position,
|
||||
originGood: updated.originGood,
|
||||
goodTags: updated.goodTags,
|
||||
});
|
||||
});
|
||||
if (
|
||||
@@ -308,8 +308,8 @@ export class GoodsService {
|
||||
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async remove(id: bigint): Promise<{ id: string }> {
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
@@ -327,80 +327,80 @@ export class GoodsService {
|
||||
}
|
||||
}
|
||||
});
|
||||
return { id: id.toString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates priorities in a single transaction; either all rows update
|
||||
* or none do.
|
||||
*/
|
||||
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
|
||||
return { id: id.toString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates priorities in a single transaction; either all rows update
|
||||
* or none do.
|
||||
*/
|
||||
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of dto.items) {
|
||||
await tx.good.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: { goodPriority: item.priority },
|
||||
});
|
||||
}
|
||||
for (const item of dto.items) {
|
||||
await tx.good.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: { goodPriority: item.priority },
|
||||
});
|
||||
}
|
||||
return { count: dto.items.length };
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates multiple goods atomically, sharing countryId/categoryId/tagIds/positionId
|
||||
* and a default priority that may be overridden per item.
|
||||
*/
|
||||
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
|
||||
const defaultPriority = dto.defaultPriority ?? 0;
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates multiple goods atomically, sharing countryId/categoryId/tagIds/positionId
|
||||
* and a default priority that may be overridden per item.
|
||||
*/
|
||||
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
|
||||
const defaultPriority = dto.defaultPriority ?? 0;
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const created: GoodDto[] = [];
|
||||
for (const item of dto.items) {
|
||||
const og = await tx.originGood.findUnique({
|
||||
where: { id: BigInt(item.originGoodId) },
|
||||
});
|
||||
if (!og) {
|
||||
throw new BadRequestException(
|
||||
`Origin good ${item.originGoodId} not found`,
|
||||
);
|
||||
}
|
||||
const row = await tx.good.create({
|
||||
data: {
|
||||
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
originGoodId: og.id,
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodPriority: item.priority ?? defaultPriority,
|
||||
},
|
||||
});
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: row.id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
created.push(GoodDto.from(result, {
|
||||
country: result.country,
|
||||
category: result.category,
|
||||
tag: result.tag,
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
}));
|
||||
}
|
||||
const created: GoodDto[] = [];
|
||||
for (const item of dto.items) {
|
||||
const og = await tx.originGood.findUnique({
|
||||
where: { id: BigInt(item.originGoodId) },
|
||||
});
|
||||
if (!og) {
|
||||
throw new BadRequestException(
|
||||
`Origin good ${item.originGoodId} not found`,
|
||||
);
|
||||
}
|
||||
const row = await tx.good.create({
|
||||
data: {
|
||||
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
originGoodId: og.id,
|
||||
countryId: BigInt(dto.countryId),
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||
goodPriority: item.priority ?? defaultPriority,
|
||||
},
|
||||
});
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
await tx.goodTag.createMany({
|
||||
data: dto.tagIds.map((tagId) => ({
|
||||
goodId: row.id,
|
||||
tagId: BigInt(tagId),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
created.push(GoodDto.from(result, {
|
||||
country: result.country,
|
||||
category: result.category,
|
||||
tag: result.tag,
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
}));
|
||||
}
|
||||
return created;
|
||||
});
|
||||
for (const goodId of new Set(
|
||||
@@ -416,64 +416,64 @@ export class GoodsService {
|
||||
this.syncService.queueProductDetailSync(goodId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the category tree and return the requested id + all of its
|
||||
* descendants. We use a level-by-level BFS to keep the queries small
|
||||
* for the typical tree sizes we expect.
|
||||
*/
|
||||
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
|
||||
const ids: bigint[] = [rootId];
|
||||
let frontier: bigint[] = [rootId];
|
||||
while (frontier.length > 0) {
|
||||
const children = await this.prisma.category.findMany({
|
||||
where: { parentCategoryId: { in: frontier } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (children.length === 0) break;
|
||||
const childIds = children.map((c) => c.id);
|
||||
ids.push(...childIds);
|
||||
frontier = childIds;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async ensureOriginGood(id: number) {
|
||||
const og = await this.prisma.originGood.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
});
|
||||
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCountry(id: number) {
|
||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCategory(id: number) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the category tree and return the requested id + all of its
|
||||
* descendants. We use a level-by-level BFS to keep the queries small
|
||||
* for the typical tree sizes we expect.
|
||||
*/
|
||||
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
|
||||
const ids: bigint[] = [rootId];
|
||||
let frontier: bigint[] = [rootId];
|
||||
while (frontier.length > 0) {
|
||||
const children = await this.prisma.category.findMany({
|
||||
where: { parentCategoryId: { in: frontier } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (children.length === 0) break;
|
||||
const childIds = children.map((c) => c.id);
|
||||
ids.push(...childIds);
|
||||
frontier = childIds;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async ensureOriginGood(id: number) {
|
||||
const og = await this.prisma.originGood.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
});
|
||||
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCountry(id: number) {
|
||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCategory(id: number) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureTag(id: number) {
|
||||
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!t) throw new BadRequestException(`Tag ${id} not found`);
|
||||
}
|
||||
|
||||
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!t) throw new BadRequestException(`Tag ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureReferences(dto: CreateGoodDto) {
|
||||
await this.ensureOriginGood(dto.originGoodId);
|
||||
await this.ensureCountry(dto.countryId);
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
if (dto.positionId !== undefined) {
|
||||
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
|
||||
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||
}
|
||||
await this.ensureOriginGood(dto.originGoodId);
|
||||
await this.ensureCountry(dto.countryId);
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
for (const tagId of dto.tagIds) {
|
||||
await this.ensureTag(tagId);
|
||||
}
|
||||
}
|
||||
if (dto.positionId !== undefined) {
|
||||
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
|
||||
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePosition(id: number) {
|
||||
|
||||
@@ -31,7 +31,9 @@ async function bootstrap() {
|
||||
);
|
||||
|
||||
// Security headers (X-Content-Type-Options, X-Frame-Options, CSP, HSTS, ...)
|
||||
app.use(helmet());
|
||||
// Static assets (/uploads, /assets) are embedded cross-origin by other
|
||||
// sites, so CORP must allow cross-origin reads.
|
||||
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
|
||||
|
||||
// Parse auth cookies (HttpOnly access/refresh tokens). Resolve both the
|
||||
// namespace and its `default` interop shape so it works regardless of
|
||||
@@ -45,12 +47,14 @@ async function bootstrap() {
|
||||
|
||||
// CORS: only origins listed in CORS_ORIGINS (comma-separated) are
|
||||
// allowed. Credentials are enabled because the session lives in
|
||||
// HttpOnly cookies.
|
||||
// HttpOnly cookies. "*" disables the allowlist and reflects any origin
|
||||
// (reflected origins are required when credentials are enabled).
|
||||
const corsOrigins = (process.env.CORS_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
app.enableCors(corsOrigins.length > 0 ? { origin: corsOrigins, credentials: true } : undefined);
|
||||
const origin = corsOrigins.includes('*') ? true : corsOrigins;
|
||||
app.enableCors(corsOrigins.length > 0 ? { origin, credentials: true } : undefined);
|
||||
|
||||
// Serve uploaded files. nosniff prevents browsers from sniffing a
|
||||
// non-image content type out of an uploaded file.
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class QueryOriginGoodDto {
|
||||
@ApiProperty({ required: false, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiProperty({ required: false, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 20;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Search by goodName' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class QueryOriginGoodDto {
|
||||
@ApiProperty({ required: false, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiProperty({ required: false, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize: number = 20;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Search by goodName' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||
|
||||
@ApiTags('origin-goods')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('origin-goods')
|
||||
export class OriginGoodsController {
|
||||
constructor(private readonly service: OriginGoodsService) {}
|
||||
|
||||
@Get('tree')
|
||||
@ApiOperation({
|
||||
summary: 'Origin goods grouped by SDS category with config status',
|
||||
})
|
||||
getTree() {
|
||||
return this.service.getTree();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
|
||||
findAll(@Query() query: QueryOriginGoodDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
}
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||
|
||||
@ApiTags('origin-goods')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('origin-goods')
|
||||
export class OriginGoodsController {
|
||||
constructor(private readonly service: OriginGoodsService) {}
|
||||
|
||||
@Get('tree')
|
||||
@ApiOperation({
|
||||
summary: 'Origin goods grouped by SDS category with config status',
|
||||
})
|
||||
getTree() {
|
||||
return this.service.getTree();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Paginated list of origin goods (read-only)' })
|
||||
findAll(@Query() query: QueryOriginGoodDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OriginGoodsController } from './origin-goods.controller';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OriginGoodsController],
|
||||
providers: [OriginGoodsService],
|
||||
})
|
||||
export class OriginGoodsModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OriginGoodsController } from './origin-goods.controller';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OriginGoodsController],
|
||||
providers: [OriginGoodsService],
|
||||
})
|
||||
export class OriginGoodsModule {}
|
||||
|
||||
@@ -1,80 +1,80 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('OriginGoodsService', () => {
|
||||
let service: OriginGoodsService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
const createdSds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [OriginGoodsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(OriginGoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
// Seed 25 rows with sequential goodNames so we can paginate/filter.
|
||||
const rows = Array.from({ length: 25 }).map((_, i) => ({
|
||||
sdsGoodId: `sds-${stamp}-${i}`,
|
||||
goodName: `Origin Good ${stamp} ${i.toString().padStart(2, '0')}`,
|
||||
sdsCategoryId: `cat-${stamp}-${i % 3}`,
|
||||
}));
|
||||
await prisma.originGood.createMany({ data: rows });
|
||||
createdSds.push(...rows.map((r) => r.sdsGoodId));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdSds.length) {
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { sdsGoodId: { in: createdSds } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns paginated results', async () => {
|
||||
const page1 = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: `Origin Good ${stamp}`,
|
||||
});
|
||||
expect(page1.total).toBe(25);
|
||||
expect(page1.items.length).toBe(10);
|
||||
expect(page1.page).toBe(1);
|
||||
expect(page1.pageSize).toBe(10);
|
||||
|
||||
const page3 = await service.findAll({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
keyword: `Origin Good ${stamp}`,
|
||||
});
|
||||
expect(page3.items.length).toBe(5);
|
||||
});
|
||||
|
||||
it('searches by keyword (case insensitive)', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
keyword: `origin good ${stamp} 05`,
|
||||
});
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].goodName).toContain('05');
|
||||
});
|
||||
|
||||
it('returns empty page when no matches', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
keyword: 'definitely-does-not-exist',
|
||||
});
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { OriginGoodsService } from './origin-goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('OriginGoodsService', () => {
|
||||
let service: OriginGoodsService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
const createdSds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [OriginGoodsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(OriginGoodsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
// Seed 25 rows with sequential goodNames so we can paginate/filter.
|
||||
const rows = Array.from({ length: 25 }).map((_, i) => ({
|
||||
sdsGoodId: `sds-${stamp}-${i}`,
|
||||
goodName: `Origin Good ${stamp} ${i.toString().padStart(2, '0')}`,
|
||||
sdsCategoryId: `cat-${stamp}-${i % 3}`,
|
||||
}));
|
||||
await prisma.originGood.createMany({ data: rows });
|
||||
createdSds.push(...rows.map((r) => r.sdsGoodId));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdSds.length) {
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { sdsGoodId: { in: createdSds } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns paginated results', async () => {
|
||||
const page1 = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: `Origin Good ${stamp}`,
|
||||
});
|
||||
expect(page1.total).toBe(25);
|
||||
expect(page1.items.length).toBe(10);
|
||||
expect(page1.page).toBe(1);
|
||||
expect(page1.pageSize).toBe(10);
|
||||
|
||||
const page3 = await service.findAll({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
keyword: `Origin Good ${stamp}`,
|
||||
});
|
||||
expect(page3.items.length).toBe(5);
|
||||
});
|
||||
|
||||
it('searches by keyword (case insensitive)', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
keyword: `origin good ${stamp} 05`,
|
||||
});
|
||||
expect(result.items.length).toBe(1);
|
||||
expect(result.items[0].goodName).toContain('05');
|
||||
});
|
||||
|
||||
it('returns empty page when no matches', async () => {
|
||||
const result = await service.findAll({
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
keyword: 'definitely-does-not-exist',
|
||||
});
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,306 +1,306 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||
|
||||
export interface PaginatedOriginGoods {
|
||||
items: Array<{
|
||||
id: string;
|
||||
sdsGoodId: string;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
sdsCategoryId: string | null;
|
||||
createdAt: string;
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||
|
||||
export interface PaginatedOriginGoods {
|
||||
items: Array<{
|
||||
id: string;
|
||||
sdsGoodId: string;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
sdsCategoryId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
hasDetail: boolean;
|
||||
detailSyncedAt: string | null;
|
||||
variantCount: number;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single origin-good node inside the tree, augmented with configuration status
|
||||
* (how many `goods` rows reference it and which countries it has been configured for).
|
||||
*/
|
||||
export interface OriginGoodsTreeNode {
|
||||
id: string;
|
||||
goodName: string;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
sdsGoodId: string;
|
||||
delisted: boolean;
|
||||
configuredCount: number;
|
||||
configuredCountries: string[];
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single origin-good node inside the tree, augmented with configuration status
|
||||
* (how many `goods` rows reference it and which countries it has been configured for).
|
||||
*/
|
||||
export interface OriginGoodsTreeNode {
|
||||
id: string;
|
||||
goodName: string;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
sdsGoodId: string;
|
||||
delisted: boolean;
|
||||
configuredCount: number;
|
||||
configuredCountries: string[];
|
||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[];
|
||||
hasDetail: boolean;
|
||||
detailSyncedAt: string | null;
|
||||
variantCount: number;
|
||||
sizeRowCount: number;
|
||||
packageRowCount: number;
|
||||
}
|
||||
|
||||
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
||||
export interface OriginGoodsTreeCategoryNode {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
sdsCategoryId: string | null;
|
||||
configuredCount: number;
|
||||
totalCount: number;
|
||||
children: OriginGoodsTreeCategoryNode[];
|
||||
originGoods: OriginGoodsTreeNode[];
|
||||
}
|
||||
|
||||
/** Top-level tree response returned by `OriginGoodsService.getTree()`. */
|
||||
export interface OriginGoodsTreeResponse {
|
||||
tree: OriginGoodsTreeCategoryNode[];
|
||||
totalOriginGoods: number;
|
||||
configuredCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OriginGoodsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
||||
const { page, pageSize, keyword } = query;
|
||||
}
|
||||
|
||||
/** A category node in the hierarchical tree, with origin goods as leaves. */
|
||||
export interface OriginGoodsTreeCategoryNode {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
sdsCategoryId: string | null;
|
||||
configuredCount: number;
|
||||
totalCount: number;
|
||||
children: OriginGoodsTreeCategoryNode[];
|
||||
originGoods: OriginGoodsTreeNode[];
|
||||
}
|
||||
|
||||
/** Top-level tree response returned by `OriginGoodsService.getTree()`. */
|
||||
export interface OriginGoodsTreeResponse {
|
||||
tree: OriginGoodsTreeCategoryNode[];
|
||||
totalOriginGoods: number;
|
||||
configuredCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OriginGoodsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
||||
const { page, pageSize, keyword } = query;
|
||||
const where: Prisma.OriginGoodWhereInput = {
|
||||
source: 'SDS',
|
||||
...(keyword
|
||||
? { goodName: { contains: keyword, mode: 'insensitive' as const } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.originGood.count({ where }),
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.originGood.count({ where }),
|
||||
this.prisma.originGood.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
include: { detail: true, _count: { select: { variants: true } } },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
sdsGoodId: r.sdsGoodId,
|
||||
goodName: r.goodName,
|
||||
goodImage: r.goodImage,
|
||||
goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(),
|
||||
sdsCategoryId: r.sdsCategoryId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
sdsGoodId: r.sdsGoodId,
|
||||
goodName: r.goodName,
|
||||
goodImage: r.goodImage,
|
||||
goodPrice: r.goodPrice === null || r.goodPrice === undefined ? null : r.goodPrice.toString(),
|
||||
sdsCategoryId: r.sdsCategoryId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
hasDetail: Boolean(r.detail),
|
||||
detailSyncedAt: r.detail?.syncedAt.toISOString() ?? null,
|
||||
variantCount: r._count.variants,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a hierarchical tree using the `categories` table parent-child
|
||||
* structure, placing each origin-good as a leaf under the category whose
|
||||
* `sdsCategoryId` matches the origin-good's `sdsCategoryId`.
|
||||
*
|
||||
* Origin-goods whose `sdsCategoryId` doesn't map to any category are placed
|
||||
* under a synthetic "未分类" root node.
|
||||
*/
|
||||
async getTree(): Promise<OriginGoodsTreeResponse> {
|
||||
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] =
|
||||
await Promise.all([
|
||||
this.prisma.category.findMany({
|
||||
where: { sdsCategoryId: { not: null } },
|
||||
orderBy: { categoryName: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
categoryName: true,
|
||||
sdsCategoryId: true,
|
||||
parentCategoryId: true,
|
||||
},
|
||||
}),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a hierarchical tree using the `categories` table parent-child
|
||||
* structure, placing each origin-good as a leaf under the category whose
|
||||
* `sdsCategoryId` matches the origin-good's `sdsCategoryId`.
|
||||
*
|
||||
* Origin-goods whose `sdsCategoryId` doesn't map to any category are placed
|
||||
* under a synthetic "未分类" root node.
|
||||
*/
|
||||
async getTree(): Promise<OriginGoodsTreeResponse> {
|
||||
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] =
|
||||
await Promise.all([
|
||||
this.prisma.category.findMany({
|
||||
where: { sdsCategoryId: { not: null } },
|
||||
orderBy: { categoryName: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
categoryName: true,
|
||||
sdsCategoryId: true,
|
||||
parentCategoryId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.originGood.findMany({
|
||||
where: { delisted: false, source: 'SDS' },
|
||||
orderBy: { goodName: 'asc' },
|
||||
include: { detail: true, _count: { select: { variants: true } } },
|
||||
}),
|
||||
this.prisma.good.groupBy({
|
||||
by: ['originGoodId'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.good.findMany({
|
||||
select: {
|
||||
originGoodId: true,
|
||||
country: { select: { countryName: true } },
|
||||
},
|
||||
distinct: ['originGoodId', 'countryId'],
|
||||
}),
|
||||
this.prisma.goodTag.findMany({
|
||||
select: {
|
||||
good: { select: { originGoodId: true } },
|
||||
tag: {
|
||||
select: {
|
||||
tagName: true,
|
||||
tagColor: true,
|
||||
tagFontColor: true,
|
||||
tagGroupId: true,
|
||||
sortOrder: true,
|
||||
tagGroup: { select: { groupName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const countMap = new Map<string, number>();
|
||||
configCounts.forEach((c) =>
|
||||
countMap.set(c.originGoodId.toString(), c._count._all),
|
||||
);
|
||||
|
||||
const countryMap = new Map<string, string[]>();
|
||||
goodsWithCountries.forEach((g) => {
|
||||
const key = g.originGoodId.toString();
|
||||
const name = g.country?.countryName;
|
||||
if (!name) return;
|
||||
const arr = countryMap.get(key);
|
||||
if (arr) arr.push(name);
|
||||
else countryMap.set(key, [name]);
|
||||
});
|
||||
|
||||
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
|
||||
goodsWithTags.forEach((gt) => {
|
||||
const key = gt.good.originGoodId.toString();
|
||||
const tagInfo = {
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
tagGroupId: gt.tag.tagGroupId?.toString() ?? null,
|
||||
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
|
||||
sortOrder: gt.tag.sortOrder,
|
||||
};
|
||||
const arr = tagMap.get(key);
|
||||
if (arr) {
|
||||
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
||||
} else {
|
||||
tagMap.set(key, [tagInfo]);
|
||||
}
|
||||
});
|
||||
|
||||
const sdsToCategory = new Map<
|
||||
string,
|
||||
(typeof allCategories)[number]
|
||||
>();
|
||||
for (const c of allCategories) {
|
||||
if (c.sdsCategoryId) sdsToCategory.set(c.sdsCategoryId, c);
|
||||
}
|
||||
|
||||
const ogToCategory = new Map<string, string>();
|
||||
for (const og of allOriginGoods) {
|
||||
if (og.sdsCategoryId && sdsToCategory.has(og.sdsCategoryId)) {
|
||||
ogToCategory.set(og.id.toString(), sdsToCategory.get(og.sdsCategoryId)!.id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const buildNode = (
|
||||
cat: (typeof allCategories)[number],
|
||||
): OriginGoodsTreeCategoryNode => {
|
||||
const childrenCats = allCategories.filter(
|
||||
(c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id,
|
||||
);
|
||||
const childNodes = childrenCats
|
||||
.map(buildNode)
|
||||
.filter((n) => n.totalCount > 0);
|
||||
|
||||
const ogsForThisCat = allOriginGoods.filter(
|
||||
(og) => ogToCategory.get(og.id.toString()) === cat.id.toString(),
|
||||
);
|
||||
const ogNodes: OriginGoodsTreeNode[] = ogsForThisCat.map((og) => ({
|
||||
id: og.id.toString(),
|
||||
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
goodPrice: og.goodPrice?.toString() ?? null,
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
delisted: og.delisted,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
this.prisma.good.groupBy({
|
||||
by: ['originGoodId'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.good.findMany({
|
||||
select: {
|
||||
originGoodId: true,
|
||||
country: { select: { countryName: true } },
|
||||
},
|
||||
distinct: ['originGoodId', 'countryId'],
|
||||
}),
|
||||
this.prisma.goodTag.findMany({
|
||||
select: {
|
||||
good: { select: { originGoodId: true } },
|
||||
tag: {
|
||||
select: {
|
||||
tagName: true,
|
||||
tagColor: true,
|
||||
tagFontColor: true,
|
||||
tagGroupId: true,
|
||||
sortOrder: true,
|
||||
tagGroup: { select: { groupName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const countMap = new Map<string, number>();
|
||||
configCounts.forEach((c) =>
|
||||
countMap.set(c.originGoodId.toString(), c._count._all),
|
||||
);
|
||||
|
||||
const countryMap = new Map<string, string[]>();
|
||||
goodsWithCountries.forEach((g) => {
|
||||
const key = g.originGoodId.toString();
|
||||
const name = g.country?.countryName;
|
||||
if (!name) return;
|
||||
const arr = countryMap.get(key);
|
||||
if (arr) arr.push(name);
|
||||
else countryMap.set(key, [name]);
|
||||
});
|
||||
|
||||
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
|
||||
goodsWithTags.forEach((gt) => {
|
||||
const key = gt.good.originGoodId.toString();
|
||||
const tagInfo = {
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
tagGroupId: gt.tag.tagGroupId?.toString() ?? null,
|
||||
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
|
||||
sortOrder: gt.tag.sortOrder,
|
||||
};
|
||||
const arr = tagMap.get(key);
|
||||
if (arr) {
|
||||
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
||||
} else {
|
||||
tagMap.set(key, [tagInfo]);
|
||||
}
|
||||
});
|
||||
|
||||
const sdsToCategory = new Map<
|
||||
string,
|
||||
(typeof allCategories)[number]
|
||||
>();
|
||||
for (const c of allCategories) {
|
||||
if (c.sdsCategoryId) sdsToCategory.set(c.sdsCategoryId, c);
|
||||
}
|
||||
|
||||
const ogToCategory = new Map<string, string>();
|
||||
for (const og of allOriginGoods) {
|
||||
if (og.sdsCategoryId && sdsToCategory.has(og.sdsCategoryId)) {
|
||||
ogToCategory.set(og.id.toString(), sdsToCategory.get(og.sdsCategoryId)!.id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const buildNode = (
|
||||
cat: (typeof allCategories)[number],
|
||||
): OriginGoodsTreeCategoryNode => {
|
||||
const childrenCats = allCategories.filter(
|
||||
(c) => c.parentCategoryId !== null && c.parentCategoryId === cat.id,
|
||||
);
|
||||
const childNodes = childrenCats
|
||||
.map(buildNode)
|
||||
.filter((n) => n.totalCount > 0);
|
||||
|
||||
const ogsForThisCat = allOriginGoods.filter(
|
||||
(og) => ogToCategory.get(og.id.toString()) === cat.id.toString(),
|
||||
);
|
||||
const ogNodes: OriginGoodsTreeNode[] = ogsForThisCat.map((og) => ({
|
||||
id: og.id.toString(),
|
||||
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
goodPrice: og.goodPrice?.toString() ?? null,
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
delisted: og.delisted,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
||||
hasDetail: Boolean(og.detail),
|
||||
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
|
||||
variantCount: og._count.variants,
|
||||
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
||||
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
|
||||
}));
|
||||
|
||||
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
|
||||
const childConfigured = childNodes.reduce(
|
||||
(s, n) => s + n.configuredCount,
|
||||
0,
|
||||
);
|
||||
const ogConfigured = ogNodes.filter((o) => o.configuredCount > 0).length;
|
||||
|
||||
return {
|
||||
categoryId: cat.id.toString(),
|
||||
categoryName: cat.categoryName,
|
||||
sdsCategoryId: cat.sdsCategoryId,
|
||||
configuredCount: childConfigured + ogConfigured,
|
||||
totalCount: childTotal + ogNodes.length,
|
||||
children: childNodes,
|
||||
originGoods: ogNodes,
|
||||
};
|
||||
};
|
||||
|
||||
const roots = allCategories.filter((c) => c.parentCategoryId === null);
|
||||
const tree = roots.map(buildNode).filter((n) => n.totalCount > 0);
|
||||
|
||||
const unmapped = allOriginGoods.filter(
|
||||
(og) => !ogToCategory.has(og.id.toString()),
|
||||
);
|
||||
if (unmapped.length > 0) {
|
||||
tree.push({
|
||||
categoryId: 'uncategorized',
|
||||
categoryName: '未分类',
|
||||
sdsCategoryId: null,
|
||||
configuredCount: unmapped.filter(
|
||||
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
||||
).length,
|
||||
totalCount: unmapped.length,
|
||||
children: [],
|
||||
originGoods: unmapped.map((og) => ({
|
||||
id: og.id.toString(),
|
||||
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
goodPrice: og.goodPrice?.toString() ?? null,
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
delisted: og.delisted,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
}));
|
||||
|
||||
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
|
||||
const childConfigured = childNodes.reduce(
|
||||
(s, n) => s + n.configuredCount,
|
||||
0,
|
||||
);
|
||||
const ogConfigured = ogNodes.filter((o) => o.configuredCount > 0).length;
|
||||
|
||||
return {
|
||||
categoryId: cat.id.toString(),
|
||||
categoryName: cat.categoryName,
|
||||
sdsCategoryId: cat.sdsCategoryId,
|
||||
configuredCount: childConfigured + ogConfigured,
|
||||
totalCount: childTotal + ogNodes.length,
|
||||
children: childNodes,
|
||||
originGoods: ogNodes,
|
||||
};
|
||||
};
|
||||
|
||||
const roots = allCategories.filter((c) => c.parentCategoryId === null);
|
||||
const tree = roots.map(buildNode).filter((n) => n.totalCount > 0);
|
||||
|
||||
const unmapped = allOriginGoods.filter(
|
||||
(og) => !ogToCategory.has(og.id.toString()),
|
||||
);
|
||||
if (unmapped.length > 0) {
|
||||
tree.push({
|
||||
categoryId: 'uncategorized',
|
||||
categoryName: '未分类',
|
||||
sdsCategoryId: null,
|
||||
configuredCount: unmapped.filter(
|
||||
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
||||
).length,
|
||||
totalCount: unmapped.length,
|
||||
children: [],
|
||||
originGoods: unmapped.map((og) => ({
|
||||
id: og.id.toString(),
|
||||
goodName: og.goodName ?? `SDS-${og.sdsGoodId}`,
|
||||
goodImage: og.goodImage,
|
||||
goodPrice: og.goodPrice?.toString() ?? null,
|
||||
sdsGoodId: og.sdsGoodId,
|
||||
delisted: og.delisted,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
||||
hasDetail: Boolean(og.detail),
|
||||
detailSyncedAt: og.detail?.syncedAt.toISOString() ?? null,
|
||||
variantCount: og._count.variants,
|
||||
sizeRowCount: this.jsonRows(og.detail?.sizeChart),
|
||||
packageRowCount: this.jsonRows(og.detail?.packageSpecs),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
tree.sort((a, b) => a.categoryName.localeCompare(b.categoryName, 'zh'));
|
||||
|
||||
const totalConfigured = allOriginGoods.filter(
|
||||
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
||||
).length;
|
||||
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
tree.sort((a, b) => a.categoryName.localeCompare(b.categoryName, 'zh'));
|
||||
|
||||
const totalConfigured = allOriginGoods.filter(
|
||||
(og) => (countMap.get(og.id.toString()) ?? 0) > 0,
|
||||
).length;
|
||||
|
||||
return {
|
||||
tree,
|
||||
totalOriginGoods: allOriginGoods.length,
|
||||
configuredCount: totalConfigured,
|
||||
tree,
|
||||
totalOriginGoods: allOriginGoods.length,
|
||||
configuredCount: totalConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePositionDto {
|
||||
@ApiProperty({ description: 'Sort order / weight' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
indexVal!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePositionDto {
|
||||
@ApiProperty({ description: 'Sort order / weight' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
indexVal!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdatePositionDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
indexVal?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number | null;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdatePositionDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
indexVal?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
countryId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId?: number | null;
|
||||
}
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { PositionsService } from './positions.service';
|
||||
import { CreatePositionDto } from './dto/create-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
|
||||
@ApiTags('positions')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('positions')
|
||||
export class PositionsController {
|
||||
constructor(private readonly service: PositionsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List positions (optionally filtered)' })
|
||||
@ApiQuery({ name: 'countryId', required: false, type: Number })
|
||||
@ApiQuery({ name: 'categoryId', required: false, type: Number })
|
||||
findAll(
|
||||
@Query('countryId') countryId?: string,
|
||||
@Query('categoryId') categoryId?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
countryId: countryId ? BigInt(countryId) : undefined,
|
||||
categoryId: categoryId ? BigInt(categoryId) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one position' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a position' })
|
||||
create(@Body() dto: CreatePositionDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a position' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdatePositionDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a position' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { PositionsService } from './positions.service';
|
||||
import { CreatePositionDto } from './dto/create-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
|
||||
@ApiTags('positions')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('positions')
|
||||
export class PositionsController {
|
||||
constructor(private readonly service: PositionsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List positions (optionally filtered)' })
|
||||
@ApiQuery({ name: 'countryId', required: false, type: Number })
|
||||
@ApiQuery({ name: 'categoryId', required: false, type: Number })
|
||||
findAll(
|
||||
@Query('countryId') countryId?: string,
|
||||
@Query('categoryId') categoryId?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
countryId: countryId ? BigInt(countryId) : undefined,
|
||||
categoryId: categoryId ? BigInt(categoryId) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one position' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a position' })
|
||||
create(@Body() dto: CreatePositionDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a position' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdatePositionDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a position' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PositionsController } from './positions.controller';
|
||||
import { PositionsService } from './positions.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PositionsController],
|
||||
providers: [PositionsService],
|
||||
exports: [PositionsService],
|
||||
})
|
||||
export class PositionsModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PositionsController } from './positions.controller';
|
||||
import { PositionsService } from './positions.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PositionsController],
|
||||
providers: [PositionsService],
|
||||
exports: [PositionsService],
|
||||
})
|
||||
export class PositionsModule {}
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { PositionsService } from './positions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('PositionsService', () => {
|
||||
let service: PositionsService;
|
||||
let prisma: PrismaService;
|
||||
let countryId: bigint;
|
||||
let categoryId: bigint;
|
||||
const createdIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PositionsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(PositionsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const stamp = Date.now();
|
||||
const c = await prisma.country.create({
|
||||
data: { countryName: `Pos Country ${stamp}` },
|
||||
});
|
||||
countryId = c.id;
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Pos Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdIds.length) {
|
||||
await prisma.position.deleteMany({ where: { id: { in: createdIds } } });
|
||||
}
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates a position linked to country + category and returns joined names', async () => {
|
||||
const created = await service.create({
|
||||
indexVal: 1,
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
createdIds.push(created.id);
|
||||
|
||||
expect(created.country?.countryName).toBeTruthy();
|
||||
expect(created.category?.categoryName).toBeTruthy();
|
||||
|
||||
const fetched = await service.findOne(created.id);
|
||||
expect(fetched.country?.id).toBe(countryId);
|
||||
expect(fetched.category?.id).toBe(categoryId);
|
||||
});
|
||||
|
||||
it('filters by countryId and categoryId', async () => {
|
||||
const list = await service.findAll({
|
||||
countryId,
|
||||
categoryId,
|
||||
});
|
||||
expect(list.length).toBeGreaterThan(0);
|
||||
expect(list.every((p) => p.countryId === countryId)).toBe(true);
|
||||
expect(list.every((p) => p.categoryId === categoryId)).toBe(true);
|
||||
});
|
||||
|
||||
it('updates indexVal and disconnects country', async () => {
|
||||
const created = await service.create({
|
||||
indexVal: 5,
|
||||
countryId: Number(countryId),
|
||||
});
|
||||
createdIds.push(created.id);
|
||||
const updated = await service.update(created.id, {
|
||||
indexVal: 9,
|
||||
countryId: null,
|
||||
});
|
||||
expect(updated.indexVal).toBe(9);
|
||||
expect(updated.countryId).toBeNull();
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown country during create', async () => {
|
||||
await expect(
|
||||
service.create({ indexVal: 1, countryId: 99999999 }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('deletes a position', async () => {
|
||||
const created = await service.create({ indexVal: 7 });
|
||||
await service.remove(created.id);
|
||||
const list = await service.findAll();
|
||||
expect(list.find((p) => p.id === created.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { PositionsService } from './positions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('PositionsService', () => {
|
||||
let service: PositionsService;
|
||||
let prisma: PrismaService;
|
||||
let countryId: bigint;
|
||||
let categoryId: bigint;
|
||||
const createdIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PositionsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(PositionsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const stamp = Date.now();
|
||||
const c = await prisma.country.create({
|
||||
data: { countryName: `Pos Country ${stamp}` },
|
||||
});
|
||||
countryId = c.id;
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Pos Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdIds.length) {
|
||||
await prisma.position.deleteMany({ where: { id: { in: createdIds } } });
|
||||
}
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates a position linked to country + category and returns joined names', async () => {
|
||||
const created = await service.create({
|
||||
indexVal: 1,
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
createdIds.push(created.id);
|
||||
|
||||
expect(created.country?.countryName).toBeTruthy();
|
||||
expect(created.category?.categoryName).toBeTruthy();
|
||||
|
||||
const fetched = await service.findOne(created.id);
|
||||
expect(fetched.country?.id).toBe(countryId);
|
||||
expect(fetched.category?.id).toBe(categoryId);
|
||||
});
|
||||
|
||||
it('filters by countryId and categoryId', async () => {
|
||||
const list = await service.findAll({
|
||||
countryId,
|
||||
categoryId,
|
||||
});
|
||||
expect(list.length).toBeGreaterThan(0);
|
||||
expect(list.every((p) => p.countryId === countryId)).toBe(true);
|
||||
expect(list.every((p) => p.categoryId === categoryId)).toBe(true);
|
||||
});
|
||||
|
||||
it('updates indexVal and disconnects country', async () => {
|
||||
const created = await service.create({
|
||||
indexVal: 5,
|
||||
countryId: Number(countryId),
|
||||
});
|
||||
createdIds.push(created.id);
|
||||
const updated = await service.update(created.id, {
|
||||
indexVal: 9,
|
||||
countryId: null,
|
||||
});
|
||||
expect(updated.indexVal).toBe(9);
|
||||
expect(updated.countryId).toBeNull();
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown country during create', async () => {
|
||||
await expect(
|
||||
service.create({ indexVal: 1, countryId: 99999999 }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('deletes a position', async () => {
|
||||
const created = await service.create({ indexVal: 7 });
|
||||
await service.remove(created.id);
|
||||
const list = await service.findAll();
|
||||
expect(list.find((p) => p.id === created.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,93 +1,93 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePositionDto } from './dto/create-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PositionsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(filters?: { countryId?: bigint; categoryId?: bigint }) {
|
||||
const where: { countryId?: bigint; categoryId?: bigint } = {};
|
||||
if (filters?.countryId !== undefined) where.countryId = filters.countryId;
|
||||
if (filters?.categoryId !== undefined) where.categoryId = filters.categoryId;
|
||||
return this.prisma.position.findMany({
|
||||
where,
|
||||
include: { country: true, category: true },
|
||||
orderBy: { indexVal: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const p = await this.prisma.position.findUnique({
|
||||
where: { id },
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException(`Position ${id} not found`);
|
||||
return p;
|
||||
}
|
||||
|
||||
async create(dto: CreatePositionDto) {
|
||||
if (dto.countryId !== undefined) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
}
|
||||
if (dto.categoryId !== undefined) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
}
|
||||
return this.prisma.position.create({
|
||||
data: {
|
||||
indexVal: dto.indexVal,
|
||||
countryId: dto.countryId === undefined ? null : BigInt(dto.countryId),
|
||||
categoryId: dto.categoryId === undefined ? null : BigInt(dto.categoryId),
|
||||
},
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdatePositionDto) {
|
||||
await this.findOne(id);
|
||||
if (dto.countryId !== undefined && dto.countryId !== null) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
}
|
||||
if (dto.categoryId !== undefined && dto.categoryId !== null) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
}
|
||||
return this.prisma.position.update({
|
||||
where: { id },
|
||||
data: {
|
||||
indexVal: dto.indexVal,
|
||||
country:
|
||||
dto.countryId === undefined
|
||||
? undefined
|
||||
: dto.countryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.countryId) } },
|
||||
category:
|
||||
dto.categoryId === undefined
|
||||
? undefined
|
||||
: dto.categoryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.categoryId) } },
|
||||
},
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.position.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureCountry(id: number) {
|
||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new NotFoundException(`Country ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCategory(id: number) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
||||
}
|
||||
}
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePositionDto } from './dto/create-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PositionsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll(filters?: { countryId?: bigint; categoryId?: bigint }) {
|
||||
const where: { countryId?: bigint; categoryId?: bigint } = {};
|
||||
if (filters?.countryId !== undefined) where.countryId = filters.countryId;
|
||||
if (filters?.categoryId !== undefined) where.categoryId = filters.categoryId;
|
||||
return this.prisma.position.findMany({
|
||||
where,
|
||||
include: { country: true, category: true },
|
||||
orderBy: { indexVal: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const p = await this.prisma.position.findUnique({
|
||||
where: { id },
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException(`Position ${id} not found`);
|
||||
return p;
|
||||
}
|
||||
|
||||
async create(dto: CreatePositionDto) {
|
||||
if (dto.countryId !== undefined) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
}
|
||||
if (dto.categoryId !== undefined) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
}
|
||||
return this.prisma.position.create({
|
||||
data: {
|
||||
indexVal: dto.indexVal,
|
||||
countryId: dto.countryId === undefined ? null : BigInt(dto.countryId),
|
||||
categoryId: dto.categoryId === undefined ? null : BigInt(dto.categoryId),
|
||||
},
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdatePositionDto) {
|
||||
await this.findOne(id);
|
||||
if (dto.countryId !== undefined && dto.countryId !== null) {
|
||||
await this.ensureCountry(dto.countryId);
|
||||
}
|
||||
if (dto.categoryId !== undefined && dto.categoryId !== null) {
|
||||
await this.ensureCategory(dto.categoryId);
|
||||
}
|
||||
return this.prisma.position.update({
|
||||
where: { id },
|
||||
data: {
|
||||
indexVal: dto.indexVal,
|
||||
country:
|
||||
dto.countryId === undefined
|
||||
? undefined
|
||||
: dto.countryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.countryId) } },
|
||||
category:
|
||||
dto.categoryId === undefined
|
||||
? undefined
|
||||
: dto.categoryId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.categoryId) } },
|
||||
},
|
||||
include: { country: true, category: true },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.position.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureCountry(id: number) {
|
||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new NotFoundException(`Country ${id} not found`);
|
||||
}
|
||||
|
||||
private async ensureCategory(id: number) {
|
||||
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new NotFoundException(`Category ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
/**
|
||||
* Global Prisma module exporting {@link PrismaService}.
|
||||
*
|
||||
* Marked `@Global()` so feature modules do not need to import it again.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
/**
|
||||
* Global Prisma module exporting {@link PrismaService}.
|
||||
*
|
||||
* Marked `@Global()` so feature modules do not need to import it again.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
describe('PrismaService', () => {
|
||||
let service: PrismaService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaService],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(PrismaService);
|
||||
await service.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should be able to run a trivial query against the database', async () => {
|
||||
// Execute a raw query that does not depend on application tables
|
||||
const result = await service.$queryRaw<Array<{ now: Date }>>`SELECT now() AS now`;
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].now).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
describe('PrismaService', () => {
|
||||
let service: PrismaService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PrismaService],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(PrismaService);
|
||||
await service.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should be able to run a trivial query against the database', async () => {
|
||||
// Execute a raw query that does not depend on application tables
|
||||
const result = await service.$queryRaw<Array<{ now: Date }>>`SELECT now() AS now`;
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].now).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Global Prisma client wrapper.
|
||||
*
|
||||
* - `onModuleInit`: connects to the database so the first request
|
||||
* does not pay the connection cost.
|
||||
* - `onModuleDestroy`: cleanly disconnects during shutdown.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Global Prisma client wrapper.
|
||||
*
|
||||
* - `onModuleInit`: connects to the database so the first request
|
||||
* does not pay the connection cost.
|
||||
* - `onModuleDestroy`: cleanly disconnects during shutdown.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Category as PrismaCategory } from '@prisma/client';
|
||||
|
||||
export class PublicCategoryNodeDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
categoryIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
parentCategoryId!: string | null;
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Category as PrismaCategory } from '@prisma/client';
|
||||
|
||||
export class PublicCategoryNodeDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
categoryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
categoryIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
parentCategoryId!: string | null;
|
||||
|
||||
@ApiProperty({ type: [PublicCategoryNodeDto] })
|
||||
children!: PublicCategoryNodeDto[];
|
||||
|
||||
@ApiProperty({ description: '当前节点及其后代分类的商品数量' })
|
||||
productCount!: number;
|
||||
|
||||
|
||||
static from(category: PrismaCategory, children: PublicCategoryNodeDto[] = []): PublicCategoryNodeDto {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
categoryName: category.categoryName,
|
||||
categoryIcon: category.categoryIcon,
|
||||
parentCategoryId: category.parentCategoryId
|
||||
? category.parentCategoryId.toString()
|
||||
: null,
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
categoryName: category.categoryName,
|
||||
categoryIcon: category.categoryIcon,
|
||||
parentCategoryId: category.parentCategoryId
|
||||
? category.parentCategoryId.toString()
|
||||
: null,
|
||||
children,
|
||||
productCount: 0,
|
||||
};
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Country as PrismaCountry } from '@prisma/client';
|
||||
|
||||
export class PublicCountryDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
countryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
countryIcon!: string | null;
|
||||
|
||||
static from(country: PrismaCountry): PublicCountryDto {
|
||||
return {
|
||||
id: country.id.toString(),
|
||||
countryName: country.countryName,
|
||||
countryIcon: country.countryIcon,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Country as PrismaCountry } from '@prisma/client';
|
||||
|
||||
export class PublicCountryDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
countryName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
countryIcon!: string | null;
|
||||
|
||||
static from(country: PrismaCountry): PublicCountryDto {
|
||||
return {
|
||||
id: country.id.toString(),
|
||||
countryName: country.countryName,
|
||||
countryIcon: country.countryIcon,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ export class PublicGoodDetailDto extends PublicGoodDto {
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
media!: Record<string, unknown> | null;
|
||||
|
||||
@ApiProperty({ type: Array, description: 'Variant images grouped by color' })
|
||||
mediaByColor!: Array<{
|
||||
colorId: string | null;
|
||||
colorName: string | null;
|
||||
colorHex: string | null;
|
||||
images: string[];
|
||||
}>;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Object })
|
||||
options!: Record<string, unknown> | null;
|
||||
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PublicGoodDto {
|
||||
@ApiProperty()
|
||||
goodId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodPriority!: number;
|
||||
|
||||
@ApiProperty()
|
||||
country!: { id: string; countryName: string; countryIcon: string | null };
|
||||
|
||||
@ApiProperty()
|
||||
category!: { id: string; categoryName: string; categoryIcon: string | null };
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tag!: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null } | null;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null }>;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
position!: { id: string; indexVal: number } | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
image!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
price!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
goodPriority!: number;
|
||||
|
||||
@ApiProperty()
|
||||
country!: { id: string; countryName: string; countryIcon: string | null };
|
||||
|
||||
@ApiProperty()
|
||||
category!: { id: string; categoryName: string; categoryIcon: string | null };
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tag!: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null } | null;
|
||||
|
||||
@ApiProperty({ type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null }>;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
position!: { id: string; indexVal: number } | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
image!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
price!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { TagGroup as PrismaTagGroup } from '@prisma/client';
|
||||
|
||||
export class PublicTagGroupDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupColor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
|
||||
static from(g: PrismaTagGroup): PublicTagGroupDto {
|
||||
return {
|
||||
id: g.id.toString(),
|
||||
groupName: g.groupName,
|
||||
groupIcon: g.groupIcon,
|
||||
groupColor: g.groupColor,
|
||||
sortOrder: g.sortOrder,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { TagGroup as PrismaTagGroup } from '@prisma/client';
|
||||
|
||||
export class PublicTagGroupDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupIcon!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
groupColor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
|
||||
static from(g: PrismaTagGroup): PublicTagGroupDto {
|
||||
return {
|
||||
id: g.id.toString(),
|
||||
groupName: g.groupName,
|
||||
groupIcon: g.groupIcon,
|
||||
groupColor: g.groupColor,
|
||||
sortOrder: g.sortOrder,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Tag as PrismaTag } from '@prisma/client';
|
||||
|
||||
export class PublicTagDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
tagName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagColor!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagFontColor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
group!: { id: string; groupName: string; sortOrder: number } | null;
|
||||
|
||||
static from(tag: PrismaTag & { tagGroup?: { id: bigint; groupName: string; sortOrder: number } | null }): PublicTagDto {
|
||||
return {
|
||||
id: tag.id.toString(),
|
||||
tagName: tag.tagName,
|
||||
tagColor: tag.tagColor,
|
||||
tagFontColor: tag.tagFontColor,
|
||||
sortOrder: tag.sortOrder,
|
||||
group: tag.tagGroup
|
||||
? {
|
||||
id: tag.tagGroup.id.toString(),
|
||||
groupName: tag.tagGroup.groupName,
|
||||
sortOrder: tag.tagGroup.sortOrder,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { Tag as PrismaTag } from '@prisma/client';
|
||||
|
||||
export class PublicTagDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
tagName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagColor!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
tagFontColor!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
sortOrder!: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
group!: { id: string; groupName: string; sortOrder: number } | null;
|
||||
|
||||
static from(tag: PrismaTag & { tagGroup?: { id: bigint; groupName: string; sortOrder: number } | null }): PublicTagDto {
|
||||
return {
|
||||
id: tag.id.toString(),
|
||||
tagName: tag.tagName,
|
||||
tagColor: tag.tagColor,
|
||||
tagFontColor: tag.tagFontColor,
|
||||
sortOrder: tag.sortOrder,
|
||||
group: tag.tagGroup
|
||||
? {
|
||||
id: tag.tagGroup.id.toString(),
|
||||
groupName: tag.tagGroup.groupName,
|
||||
sortOrder: tag.tagGroup.sortOrder,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,48 +13,48 @@ import {
|
||||
ApiTags,
|
||||
getSchemaPath,
|
||||
} from '@nestjs/swagger';
|
||||
import { PublicService } from './public.service';
|
||||
import { PublicService } from './public.service';
|
||||
import {
|
||||
PublicCountryQueryDto,
|
||||
PublicHomeGoodsQueryDto,
|
||||
PublicQueryGoodDto,
|
||||
PublicTagFilterDto,
|
||||
} from './dto/public-query-good.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
|
||||
import { PublicGoodDto } from './dto/public-good.dto';
|
||||
|
||||
|
||||
@ApiTags('public')
|
||||
@ApiExtraModels(PublicTagFilterDto)
|
||||
@Controller('public')
|
||||
export class PublicController {
|
||||
constructor(private readonly service: PublicService) {}
|
||||
|
||||
export class PublicController {
|
||||
constructor(private readonly service: PublicService) {}
|
||||
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: '获取商品分类树;countryId 不传时返回全部国家' })
|
||||
getCategories(@Query() query: PublicCountryQueryDto) {
|
||||
return this.service.getCategoriesTree(query.countryId);
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
@ApiOperation({ summary: 'Public list of countries that have goods' })
|
||||
getCountries() {
|
||||
return this.service.getCountries();
|
||||
}
|
||||
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: 'Public list of tags that have goods' })
|
||||
getTags(): Promise<PublicTagDto[]> {
|
||||
return this.service.getTags();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
@ApiOperation({ summary: 'Public list of countries that have goods' })
|
||||
getCountries() {
|
||||
return this.service.getCountries();
|
||||
}
|
||||
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: 'Public list of tags that have goods' })
|
||||
getTags(): Promise<PublicTagDto[]> {
|
||||
return this.service.getTags();
|
||||
}
|
||||
|
||||
@Get('tag-groups')
|
||||
@ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' })
|
||||
@ApiOkResponse({ type: [PublicTagGroupFilterDto] })
|
||||
getTagGroups(@Query() query: PublicCountryQueryDto): Promise<PublicTagGroupFilterDto[]> {
|
||||
return this.service.getTagGroups(query.countryId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Get('goods')
|
||||
@ApiOperation({ summary: '分页获取商品' })
|
||||
@ApiQuery({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PublicController } from './public.controller';
|
||||
import { PublicService } from './public.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PublicController],
|
||||
providers: [PublicService],
|
||||
})
|
||||
export class PublicModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PublicController } from './public.controller';
|
||||
import { PublicService } from './public.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PublicController],
|
||||
providers: [PublicService],
|
||||
})
|
||||
export class PublicModule {}
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PublicService } from './public.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('PublicService', () => {
|
||||
let service: PublicService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
let countryId: bigint;
|
||||
let categoryId: bigint;
|
||||
let childCategoryId: bigint;
|
||||
let otherCategoryId: bigint;
|
||||
import { PublicService } from './public.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('PublicService', () => {
|
||||
let service: PublicService;
|
||||
let prisma: PrismaService;
|
||||
const stamp = Date.now();
|
||||
let countryId: bigint;
|
||||
let categoryId: bigint;
|
||||
let childCategoryId: bigint;
|
||||
let otherCategoryId: bigint;
|
||||
let tagId: bigint;
|
||||
let filterGroupIds: bigint[] = [];
|
||||
let filterTagIds: bigint[] = [];
|
||||
let originGoodId: bigint;
|
||||
let goodIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PublicService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(PublicService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const country = await prisma.country.create({
|
||||
data: { countryName: `Pub Country ${stamp}` },
|
||||
});
|
||||
countryId = country.id;
|
||||
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Pub Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
const child = await prisma.category.create({
|
||||
data: { categoryName: `Pub Child ${stamp}`, parentCategoryId: cat.id },
|
||||
});
|
||||
childCategoryId = child.id;
|
||||
const otherCat = await prisma.category.create({
|
||||
data: { categoryName: `Pub Other ${stamp}` },
|
||||
});
|
||||
otherCategoryId = otherCat.id;
|
||||
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName: `Pub Tag ${stamp}`, tagColor: '#0000FF' },
|
||||
});
|
||||
tagId = tag.id;
|
||||
|
||||
const og = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `pub-sds-${stamp}`,
|
||||
goodName: `Origin ${stamp}`,
|
||||
goodImage: 'http://img',
|
||||
},
|
||||
});
|
||||
originGoodId = og.id;
|
||||
|
||||
// Seed 3 goods:
|
||||
// high priority + position.indexVal=1
|
||||
// mid priority + position.indexVal=5
|
||||
// no priority + no position (falls back to createdAt)
|
||||
const pos1 = await prisma.position.create({
|
||||
data: { indexVal: 1, countryId, categoryId },
|
||||
});
|
||||
const pos2 = await prisma.position.create({
|
||||
data: { indexVal: 5, countryId, categoryId },
|
||||
});
|
||||
|
||||
const g1 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub High ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 10,
|
||||
positionId: pos1.id,
|
||||
},
|
||||
});
|
||||
const g2 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Mid ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 5,
|
||||
positionId: pos2.id,
|
||||
},
|
||||
});
|
||||
const g3 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub NoPos ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
tagId,
|
||||
goodPriority: 1,
|
||||
},
|
||||
});
|
||||
let originGoodId: bigint;
|
||||
let goodIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [PublicService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(PublicService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
|
||||
const country = await prisma.country.create({
|
||||
data: { countryName: `Pub Country ${stamp}` },
|
||||
});
|
||||
countryId = country.id;
|
||||
|
||||
const cat = await prisma.category.create({
|
||||
data: { categoryName: `Pub Cat ${stamp}` },
|
||||
});
|
||||
categoryId = cat.id;
|
||||
const child = await prisma.category.create({
|
||||
data: { categoryName: `Pub Child ${stamp}`, parentCategoryId: cat.id },
|
||||
});
|
||||
childCategoryId = child.id;
|
||||
const otherCat = await prisma.category.create({
|
||||
data: { categoryName: `Pub Other ${stamp}` },
|
||||
});
|
||||
otherCategoryId = otherCat.id;
|
||||
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName: `Pub Tag ${stamp}`, tagColor: '#0000FF' },
|
||||
});
|
||||
tagId = tag.id;
|
||||
|
||||
const og = await prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId: `pub-sds-${stamp}`,
|
||||
goodName: `Origin ${stamp}`,
|
||||
goodImage: 'http://img',
|
||||
},
|
||||
});
|
||||
originGoodId = og.id;
|
||||
|
||||
// Seed 3 goods:
|
||||
// high priority + position.indexVal=1
|
||||
// mid priority + position.indexVal=5
|
||||
// no priority + no position (falls back to createdAt)
|
||||
const pos1 = await prisma.position.create({
|
||||
data: { indexVal: 1, countryId, categoryId },
|
||||
});
|
||||
const pos2 = await prisma.position.create({
|
||||
data: { indexVal: 5, countryId, categoryId },
|
||||
});
|
||||
|
||||
const g1 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub High ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 10,
|
||||
positionId: pos1.id,
|
||||
},
|
||||
});
|
||||
const g2 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Mid ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 5,
|
||||
positionId: pos2.id,
|
||||
},
|
||||
});
|
||||
const g3 = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub NoPos ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId,
|
||||
tagId,
|
||||
goodPriority: 1,
|
||||
},
|
||||
});
|
||||
goodIds = [g1.id, g2.id, g3.id];
|
||||
|
||||
const craftGroup = await prisma.tagGroup.create({
|
||||
@@ -143,102 +143,102 @@ describe('PublicService', () => {
|
||||
price: 38,
|
||||
},
|
||||
});
|
||||
|
||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||
// returns more than one category.
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Other ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId: otherCategoryId,
|
||||
goodPriority: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// And seed a good in the *child* category, to verify categoryId
|
||||
// recursion.
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub ChildGood ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId: childCategoryId,
|
||||
goodPriority: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (goodIds.length) {
|
||||
await prisma.good.deleteMany({ where: { id: { in: goodIds } } });
|
||||
}
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Pub ` } },
|
||||
});
|
||||
await prisma.position.deleteMany({
|
||||
where: { countryId },
|
||||
});
|
||||
|
||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||
// returns more than one category.
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Other ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId: otherCategoryId,
|
||||
goodPriority: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// And seed a good in the *child* category, to verify categoryId
|
||||
// recursion.
|
||||
await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub ChildGood ${stamp}`,
|
||||
originGoodId,
|
||||
countryId,
|
||||
categoryId: childCategoryId,
|
||||
goodPriority: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (goodIds.length) {
|
||||
await prisma.good.deleteMany({ where: { id: { in: goodIds } } });
|
||||
}
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Pub ` } },
|
||||
});
|
||||
await prisma.position.deleteMany({
|
||||
where: { countryId },
|
||||
});
|
||||
await prisma.tag.delete({ where: { id: tagId } });
|
||||
await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } });
|
||||
await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } });
|
||||
await prisma.originGood.delete({ where: { id: originGoodId } });
|
||||
// Delete children before parent (FK self-relation is RESTRICT).
|
||||
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||
await prisma.category.delete({ where: { id: otherCategoryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('getCategoriesTree returns only categories that have goods', async () => {
|
||||
const tree = await service.getCategoriesTree();
|
||||
const allIds = new Set<string>();
|
||||
const walk = (list: Array<{ id: string; children: Array<{ id: string }> }>) => {
|
||||
for (const n of list) {
|
||||
allIds.add(n.id);
|
||||
walk(n.children as any);
|
||||
}
|
||||
};
|
||||
walk(tree as any);
|
||||
// We seeded goods in `categoryId`, `childCategoryId`, `otherCategoryId`.
|
||||
expect(allIds.has(categoryId.toString())).toBe(true);
|
||||
expect(allIds.has(childCategoryId.toString())).toBe(true);
|
||||
expect(allIds.has(otherCategoryId.toString())).toBe(true);
|
||||
});
|
||||
|
||||
it('getCountries returns only countries that have goods', async () => {
|
||||
const countries = await service.getCountries();
|
||||
expect(countries.find((c) => c.id === countryId.toString())).toBeDefined();
|
||||
});
|
||||
|
||||
it('filters by countryId, tagId, keyword and categoryId (recursively)', async () => {
|
||||
const filtered = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
await prisma.originGood.delete({ where: { id: originGoodId } });
|
||||
// Delete children before parent (FK self-relation is RESTRICT).
|
||||
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||
await prisma.category.delete({ where: { id: otherCategoryId } });
|
||||
await prisma.category.delete({ where: { id: categoryId } });
|
||||
await prisma.country.delete({ where: { id: countryId } });
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('getCategoriesTree returns only categories that have goods', async () => {
|
||||
const tree = await service.getCategoriesTree();
|
||||
const allIds = new Set<string>();
|
||||
const walk = (list: Array<{ id: string; children: Array<{ id: string }> }>) => {
|
||||
for (const n of list) {
|
||||
allIds.add(n.id);
|
||||
walk(n.children as any);
|
||||
}
|
||||
};
|
||||
walk(tree as any);
|
||||
// We seeded goods in `categoryId`, `childCategoryId`, `otherCategoryId`.
|
||||
expect(allIds.has(categoryId.toString())).toBe(true);
|
||||
expect(allIds.has(childCategoryId.toString())).toBe(true);
|
||||
expect(allIds.has(otherCategoryId.toString())).toBe(true);
|
||||
});
|
||||
|
||||
it('getCountries returns only countries that have goods', async () => {
|
||||
const countries = await service.getCountries();
|
||||
expect(countries.find((c) => c.id === countryId.toString())).toBeDefined();
|
||||
});
|
||||
|
||||
it('filters by countryId, tagId, keyword and categoryId (recursively)', async () => {
|
||||
const filtered = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
categoryId: categoryId.toString(), // includes child
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
||||
expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true);
|
||||
});
|
||||
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
||||
expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true);
|
||||
});
|
||||
|
||||
it('sorts by priority DESC, position.indexVal ASC, createdAt DESC', async () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
});
|
||||
const priorities = result.items.map((g) => g.goodPriority);
|
||||
// First verify primary descending priority.
|
||||
const sorted = [...priorities].sort((a, b) => b - a);
|
||||
expect(priorities).toEqual(sorted);
|
||||
keyword: `Pub `,
|
||||
});
|
||||
const priorities = result.items.map((g) => g.goodPriority);
|
||||
// First verify primary descending priority.
|
||||
const sorted = [...priorities].sort((a, b) => b - a);
|
||||
expect(priorities).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('uses OR within one tag group and AND across tag groups', async () => {
|
||||
@@ -292,16 +292,16 @@ describe('PublicService', () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
|
||||
it('returns the SDS product id as the public product id', async () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub High ${stamp}`,
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
keyword: `Pub High ${stamp}`,
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
|
||||
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
||||
});
|
||||
@@ -335,15 +335,15 @@ describe('PublicService', () => {
|
||||
await prisma.originGood.delete({ where: { id: origin.id } });
|
||||
}
|
||||
});
|
||||
|
||||
it('getGood returns detail and 404 for unknown id', async () => {
|
||||
const first = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
|
||||
it('getGood returns detail and 404 for unknown id', async () => {
|
||||
const first = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(first.items.length).toBe(1);
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(first.items.length).toBe(1);
|
||||
const detail = await service.getGood(`pub-sds-${stamp}`);
|
||||
expect(detail.goodId).toBe(first.items[0].goodId);
|
||||
expect(detail.productCode).toBe('OZ10827003');
|
||||
@@ -353,30 +353,30 @@ describe('PublicService', () => {
|
||||
expect(detail.variants).toHaveLength(1);
|
||||
|
||||
await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('getTags returns tags with their group info, sorted by group then order', async () => {
|
||||
const tags = await service.getTags();
|
||||
expect(tags.length).toBeGreaterThan(0);
|
||||
// Each tag in our seed (包邮/不包邮/...) should have a group
|
||||
const grouped = tags.find((t) => t.tagName === '包邮');
|
||||
if (grouped) {
|
||||
expect(grouped.group).not.toBeNull();
|
||||
expect(grouped.group!.groupName).toBe('物流渠道');
|
||||
}
|
||||
});
|
||||
|
||||
it('getTagGroups returns only groups that have goods', async () => {
|
||||
const groups = await service.getTagGroups();
|
||||
expect(groups.length).toBeGreaterThan(0);
|
||||
const names = groups.map((g) => g.groupName);
|
||||
expect(names).toContain('物流渠道');
|
||||
expect(names).toContain('印刷位置');
|
||||
expect(names).toContain('印刷工艺');
|
||||
// Sorted by sortOrder
|
||||
const sortOrders = groups.map((g) => g.sortOrder);
|
||||
expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders);
|
||||
});
|
||||
});
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('getTags returns tags with their group info, sorted by group then order', async () => {
|
||||
const tags = await service.getTags();
|
||||
expect(tags.length).toBeGreaterThan(0);
|
||||
// Each tag in our seed (包邮/不包邮/...) should have a group
|
||||
const grouped = tags.find((t) => t.tagName === '包邮');
|
||||
if (grouped) {
|
||||
expect(grouped.group).not.toBeNull();
|
||||
expect(grouped.group!.groupName).toBe('物流渠道');
|
||||
}
|
||||
});
|
||||
|
||||
it('getTagGroups returns only groups that have goods', async () => {
|
||||
const groups = await service.getTagGroups();
|
||||
expect(groups.length).toBeGreaterThan(0);
|
||||
const names = groups.map((g) => g.groupName);
|
||||
expect(names).toContain('物流渠道');
|
||||
expect(names).toContain('印刷位置');
|
||||
expect(names).toContain('印刷工艺');
|
||||
// Sorted by sortOrder
|
||||
const sortOrders = groups.map((g) => g.sortOrder);
|
||||
expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,7 +205,9 @@ export class PublicService {
|
||||
if (!good) {
|
||||
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||
}
|
||||
return this.toPublicGoodDetail(good);
|
||||
const dto = this.toPublicGoodDetail(good);
|
||||
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
|
||||
return dto;
|
||||
}
|
||||
|
||||
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
|
||||
@@ -270,6 +272,66 @@ export class PublicService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Group distinct variant images by color so the frontend can switch media per color.
|
||||
* Only color-specific photos are included (main / result / detail images);
|
||||
* design-layer素材图 and the product-level blank garment photo are excluded
|
||||
* because they are not per-color gallery photos. */
|
||||
private groupImagesByColor(
|
||||
variants: PublicGoodRow['originGood']['variants'],
|
||||
): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> {
|
||||
const groups = new Map<string, {
|
||||
colorId: string | null;
|
||||
colorName: string | null;
|
||||
colorHex: string | null;
|
||||
images: string[];
|
||||
}>();
|
||||
for (const variant of variants) {
|
||||
const key = variant.colorId ?? `variant:${variant.sdsVariantId}`;
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = {
|
||||
colorId: variant.colorId,
|
||||
colorName: variant.colorName,
|
||||
colorHex: variant.colorHex,
|
||||
images: [],
|
||||
};
|
||||
groups.set(key, group);
|
||||
}
|
||||
const design = (variant.designData ?? {}) as {
|
||||
detailImgUrls?: Array<{ imageUrl?: unknown }>;
|
||||
prototypeResultGroups?: Array<{ resultImage?: unknown }>;
|
||||
};
|
||||
const urls: unknown[] = [
|
||||
variant.imageUrl,
|
||||
...(design.prototypeResultGroups ?? []).map((item) => item?.resultImage),
|
||||
...(design.detailImgUrls ?? []).map((image) => image?.imageUrl),
|
||||
];
|
||||
for (const url of urls) {
|
||||
const value = typeof url === 'string' ? url.trim() : '';
|
||||
if (value && !group.images.includes(value)) {
|
||||
group.images.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/** Leaf categories often have no icon upstream; fall back to the nearest ancestor that has one. */
|
||||
private async resolveCategoryIcon(category: PublicGoodRow['category']): Promise<string | null> {
|
||||
if (category.categoryIcon) return category.categoryIcon;
|
||||
let cursor = category.parentCategoryId;
|
||||
for (let depth = 0; cursor !== null && depth < 10; depth++) {
|
||||
const parent = await this.prisma.category.findUnique({
|
||||
where: { id: cursor },
|
||||
select: { categoryIcon: true, parentCategoryId: true },
|
||||
});
|
||||
if (!parent) break;
|
||||
if (parent.categoryIcon) return parent.categoryIcon;
|
||||
cursor = parent.parentCategoryId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
|
||||
const base = this.toPublicGood(good);
|
||||
const detail = good.originGood.detail;
|
||||
@@ -292,6 +354,7 @@ export class PublicService {
|
||||
pictureRequest: detail?.pictureRequest ?? null,
|
||||
},
|
||||
media: (detail?.media as Record<string, unknown> | null) ?? null,
|
||||
mediaByColor: this.groupImagesByColor(good.originGood.variants),
|
||||
options: (detail?.options as Record<string, unknown> | null) ?? null,
|
||||
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
|
||||
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null,
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { SyncLog } from '@prisma/client';
|
||||
|
||||
export class SyncLogDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { SyncLog } from '@prisma/client';
|
||||
|
||||
export class SyncLogDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
type!: 'CATEGORIES' | 'PRODUCTS' | 'PRODUCT_DETAILS';
|
||||
|
||||
@ApiProperty()
|
||||
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
message!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
startedAt!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
finishedAt!: string | null;
|
||||
|
||||
static from(log: SyncLog): SyncLogDto {
|
||||
return {
|
||||
id: log.id.toString(),
|
||||
type: log.type,
|
||||
status: log.status,
|
||||
message: log.message,
|
||||
startedAt: log.startedAt.toISOString(),
|
||||
finishedAt: log.finishedAt ? log.finishedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiProperty()
|
||||
status!: 'RUNNING' | 'SUCCESS' | 'FAILED';
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
message!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
startedAt!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
finishedAt!: string | null;
|
||||
|
||||
static from(log: SyncLog): SyncLogDto {
|
||||
return {
|
||||
id: log.id.toString(),
|
||||
type: log.type,
|
||||
status: log.status,
|
||||
message: log.message,
|
||||
startedAt: log.startedAt.toISOString(),
|
||||
finishedAt: log.finishedAt ? log.finishedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
|
||||
describe('SdsClientService', () => {
|
||||
let service: SdsClientService;
|
||||
let http: { post: jest.Mock; get: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
http = { post: jest.fn(), get: jest.fn() };
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
SdsClientService,
|
||||
{ provide: HttpService, useValue: http },
|
||||
{ provide: ConfigService, useValue: { get: jest.fn(() => undefined) } },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SdsClientService);
|
||||
});
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
|
||||
describe('SdsClientService', () => {
|
||||
let service: SdsClientService;
|
||||
let http: { post: jest.Mock; get: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
http = { post: jest.fn(), get: jest.fn() };
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
SdsClientService,
|
||||
{ provide: HttpService, useValue: http },
|
||||
{ provide: ConfigService, useValue: { get: jest.fn(() => undefined) } },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SdsClientService);
|
||||
});
|
||||
|
||||
describe('fetchCategoryTree', () => {
|
||||
it('throws when the upstream returns a degenerate small tree', async () => {
|
||||
http.post.mockReturnValue(of({ data: [{ id: 1, name: 'Only' }] }));
|
||||
await expect(service.fetchCategoryTree()).rejects.toThrow(/degenerate/i);
|
||||
});
|
||||
|
||||
it('returns the tree when it is healthy', async () => {
|
||||
const tree = Array.from({ length: 20 }, (_, i) => ({ id: i + 1, name: `C${i}` }));
|
||||
http.post.mockReturnValue(of({ data: tree }));
|
||||
const result = await service.fetchCategoryTree();
|
||||
expect(result).toHaveLength(20);
|
||||
});
|
||||
it('throws when the upstream returns a degenerate small tree', async () => {
|
||||
http.post.mockReturnValue(of({ data: [{ id: 1, name: 'Only' }] }));
|
||||
await expect(service.fetchCategoryTree()).rejects.toThrow(/degenerate/i);
|
||||
});
|
||||
|
||||
it('returns the tree when it is healthy', async () => {
|
||||
const tree = Array.from({ length: 20 }, (_, i) => ({ id: i + 1, name: `C${i}` }));
|
||||
http.post.mockReturnValue(of({ data: tree }));
|
||||
const result = await service.fetchCategoryTree();
|
||||
expect(result).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchProductDetail', () => {
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
const POD_HEADERS = {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
Origin: 'https://inkpod.vip',
|
||||
Referer: 'https://inkpod.vip/',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Minimum number of category nodes a healthy `category/tree/3` response contains.
|
||||
* Below this the response is treated as degenerate and rejected so the caller
|
||||
* never runs a destructive sync against a partial tree.
|
||||
*/
|
||||
export const MIN_SDS_CATEGORY_NODES = 10;
|
||||
|
||||
export interface SdsCategoryTreeNode {
|
||||
id: number | string | null;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
children?: SdsCategoryTreeNode[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SdsProduct {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
pic?: string;
|
||||
image?: string;
|
||||
psd_img_url?: string;
|
||||
thumbImgUrl?: string;
|
||||
blankDesignUrl?: string;
|
||||
img_url?: string;
|
||||
show_img?: string;
|
||||
price?: number | string;
|
||||
currentPrice?: number | string;
|
||||
categoryId?: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
const POD_HEADERS = {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
Origin: 'https://inkpod.vip',
|
||||
Referer: 'https://inkpod.vip/',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Minimum number of category nodes a healthy `category/tree/3` response contains.
|
||||
* Below this the response is treated as degenerate and rejected so the caller
|
||||
* never runs a destructive sync against a partial tree.
|
||||
*/
|
||||
export const MIN_SDS_CATEGORY_NODES = 10;
|
||||
|
||||
export interface SdsCategoryTreeNode {
|
||||
id: number | string | null;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
children?: SdsCategoryTreeNode[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SdsProduct {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
pic?: string;
|
||||
image?: string;
|
||||
psd_img_url?: string;
|
||||
thumbImgUrl?: string;
|
||||
blankDesignUrl?: string;
|
||||
img_url?: string;
|
||||
show_img?: string;
|
||||
price?: number | string;
|
||||
currentPrice?: number | string;
|
||||
categoryId?: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SdsProductsPage {
|
||||
items?: SdsProduct[];
|
||||
content?: SdsProduct[];
|
||||
totalCount?: number;
|
||||
totalElements?: number;
|
||||
total?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
[key: string]: unknown;
|
||||
items?: SdsProduct[];
|
||||
content?: SdsProduct[];
|
||||
totalCount?: number;
|
||||
totalElements?: number;
|
||||
total?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SdsProductVariant extends Record<string, unknown> {
|
||||
@@ -131,90 +131,90 @@ export interface SdsProductDetail extends Record<string, unknown> {
|
||||
items?: SdsProductVariant[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
|
||||
* `SyncService` consumes.
|
||||
*
|
||||
* Exposed as its own service so it can be mocked cleanly in unit tests.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SdsClientService {
|
||||
private readonly logger = new Logger(SdsClientService.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
this.baseUrl =
|
||||
config.get<string>('SDS_API_BASE')?.replace(/\/$/, '') ??
|
||||
'https://mapi.sdspod.com';
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: 'post' | 'get',
|
||||
url: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const MAX_RETRIES = 3;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const config = {
|
||||
headers: POD_HEADERS,
|
||||
timeout: 30000,
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
const obs =
|
||||
method === 'post'
|
||||
? this.http.post<T>(url, body, config)
|
||||
: this.http.get<T>(url, config);
|
||||
const { data } = await firstValueFrom(obs);
|
||||
return data as T;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (attempt < MAX_RETRIES) {
|
||||
this.logger.warn(`SDS request attempt ${attempt}/${MAX_RETRIES} failed: ${msg}`);
|
||||
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async fetchCategoryTree(): Promise<SdsCategoryTreeNode[]> {
|
||||
const url = `${this.baseUrl}/category/tree/3`;
|
||||
const body = {
|
||||
withActivityArea: true,
|
||||
withPrivate: true,
|
||||
onlyHaveProduct: true,
|
||||
};
|
||||
const data = await this.request<unknown>('post', url, body);
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`SDS category tree returned ${typeof data}, expected array`);
|
||||
}
|
||||
if (data.length < MIN_SDS_CATEGORY_NODES) {
|
||||
throw new Error(
|
||||
`SDS category tree is degenerate (${data.length} nodes < ${MIN_SDS_CATEGORY_NODES}) — ` +
|
||||
`aborting to avoid destructive sync`,
|
||||
);
|
||||
}
|
||||
return data as SdsCategoryTreeNode[];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
|
||||
* `SyncService` consumes.
|
||||
*
|
||||
* Exposed as its own service so it can be mocked cleanly in unit tests.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SdsClientService {
|
||||
private readonly logger = new Logger(SdsClientService.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
this.baseUrl =
|
||||
config.get<string>('SDS_API_BASE')?.replace(/\/$/, '') ??
|
||||
'https://mapi.sdspod.com';
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: 'post' | 'get',
|
||||
url: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const MAX_RETRIES = 3;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const config = {
|
||||
headers: POD_HEADERS,
|
||||
timeout: 30000,
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
const obs =
|
||||
method === 'post'
|
||||
? this.http.post<T>(url, body, config)
|
||||
: this.http.get<T>(url, config);
|
||||
const { data } = await firstValueFrom(obs);
|
||||
return data as T;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (attempt < MAX_RETRIES) {
|
||||
this.logger.warn(`SDS request attempt ${attempt}/${MAX_RETRIES} failed: ${msg}`);
|
||||
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async fetchCategoryTree(): Promise<SdsCategoryTreeNode[]> {
|
||||
const url = `${this.baseUrl}/category/tree/3`;
|
||||
const body = {
|
||||
withActivityArea: true,
|
||||
withPrivate: true,
|
||||
onlyHaveProduct: true,
|
||||
};
|
||||
const data = await this.request<unknown>('post', url, body);
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`SDS category tree returned ${typeof data}, expected array`);
|
||||
}
|
||||
if (data.length < MIN_SDS_CATEGORY_NODES) {
|
||||
throw new Error(
|
||||
`SDS category tree is degenerate (${data.length} nodes < ${MIN_SDS_CATEGORY_NODES}) — ` +
|
||||
`aborting to avoid destructive sync`,
|
||||
);
|
||||
}
|
||||
return data as SdsCategoryTreeNode[];
|
||||
}
|
||||
|
||||
async fetchProductsPage(
|
||||
categoryId: string | number,
|
||||
page = 1,
|
||||
size = 50,
|
||||
): Promise<SdsProductsPage> {
|
||||
const url = `${this.baseUrl}/products/page`;
|
||||
const data = await this.request<unknown>('get', url, undefined, { categoryId, page, size });
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error(`SDS products page returned ${typeof data}, expected object`);
|
||||
}
|
||||
categoryId: string | number,
|
||||
page = 1,
|
||||
size = 50,
|
||||
): Promise<SdsProductsPage> {
|
||||
const url = `${this.baseUrl}/products/page`;
|
||||
const data = await this.request<unknown>('get', url, undefined, { categoryId, page, size });
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error(`SDS products page returned ${typeof data}, expected object`);
|
||||
}
|
||||
return data as SdsProductsPage;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
import {
|
||||
Controller,
|
||||
import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SyncLogDto } from './dto/sync-log.dto';
|
||||
|
||||
@ApiTags('sync')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('sync')
|
||||
export class SyncController {
|
||||
constructor(private readonly service: SyncService) {}
|
||||
|
||||
@Post('categories')
|
||||
@ApiOperation({ summary: 'Manually trigger category sync (async)' })
|
||||
async syncCategories() {
|
||||
return this.service.startCategorySync();
|
||||
}
|
||||
|
||||
@Post('products')
|
||||
@ApiOperation({ summary: 'Manually trigger product sync (async)' })
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SyncLogDto } from './dto/sync-log.dto';
|
||||
|
||||
@ApiTags('sync')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('sync')
|
||||
export class SyncController {
|
||||
constructor(private readonly service: SyncService) {}
|
||||
|
||||
@Post('categories')
|
||||
@ApiOperation({ summary: 'Manually trigger category sync (async)' })
|
||||
async syncCategories() {
|
||||
return this.service.startCategorySync();
|
||||
}
|
||||
|
||||
@Post('products')
|
||||
@ApiOperation({ summary: 'Manually trigger product sync (async)' })
|
||||
async syncProducts() {
|
||||
return this.service.startProductSync();
|
||||
}
|
||||
@@ -48,14 +48,14 @@ export class SyncController {
|
||||
async syncOneProductDetail(@Param('goodId') goodId: string) {
|
||||
return this.service.syncOneProductDetail(goodId);
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Recent sync log entries' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async status(
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
): Promise<SyncLogDto[]> {
|
||||
const logs = await this.service.getStatus(limit);
|
||||
return logs.map((l) => SyncLogDto.from(l));
|
||||
}
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Recent sync log entries' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async status(
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
): Promise<SyncLogDto[]> {
|
||||
const logs = await this.service.getStatus(limit);
|
||||
return logs.map((l) => SyncLogDto.from(l));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), HttpModule],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService, SdsClientService],
|
||||
exports: [SyncService, SdsClientService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), HttpModule],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService, SdsClientService],
|
||||
exports: [SyncService, SdsClientService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
|
||||
@@ -1,280 +1,280 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import {
|
||||
SyncService,
|
||||
shouldRunDelistDetection,
|
||||
shouldSkipStaleDeletion,
|
||||
} from './sync.service';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import {
|
||||
SyncService,
|
||||
shouldRunDelistDetection,
|
||||
shouldSkipStaleDeletion,
|
||||
} from './sync.service';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('SyncService', () => {
|
||||
let service: SyncService;
|
||||
let sds: jest.Mocked<SdsClientService>;
|
||||
let prisma: PrismaService;
|
||||
const createdSdsCategoryIds: string[] = [];
|
||||
const createdSdsGoodIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const sdsMock: Partial<SdsClientService> = {
|
||||
let service: SyncService;
|
||||
let sds: jest.Mocked<SdsClientService>;
|
||||
let prisma: PrismaService;
|
||||
const createdSdsCategoryIds: string[] = [];
|
||||
const createdSdsGoodIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const sdsMock: Partial<SdsClientService> = {
|
||||
fetchCategoryTree: jest.fn(),
|
||||
fetchProductsPage: jest.fn(),
|
||||
fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
|
||||
};
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
||||
providers: [
|
||||
SyncService,
|
||||
{ provide: SdsClientService, useValue: sdsMock },
|
||||
PrismaService,
|
||||
],
|
||||
}).compile();
|
||||
};
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
||||
providers: [
|
||||
SyncService,
|
||||
{ provide: SdsClientService, useValue: sdsMock },
|
||||
PrismaService,
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SyncService);
|
||||
jest
|
||||
.spyOn(service, 'syncConfiguredProductDetails')
|
||||
.mockResolvedValue({ synced: 0, failed: 0 });
|
||||
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdSdsCategoryIds.length) {
|
||||
await prisma.category.deleteMany({
|
||||
where: { sdsCategoryId: { in: createdSdsCategoryIds } },
|
||||
});
|
||||
}
|
||||
if (createdSdsGoodIds.length) {
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { sdsGoodId: { in: createdSdsGoodIds } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('flattenCategoryTree', () => {
|
||||
it('flattens a nested SDS tree and preserves parent linkage', () => {
|
||||
const tree = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Root',
|
||||
children: [
|
||||
{ id: 11, name: 'Child A' },
|
||||
{ id: 12, name: 'Child B', children: [{ id: 121, name: 'Leaf' }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
const flat = service.flattenCategoryTree(tree);
|
||||
expect(flat).toHaveLength(4);
|
||||
const byId = Object.fromEntries(flat.map((n) => [n.sdsId, n]));
|
||||
expect(byId['1'].name).toBe('Root');
|
||||
expect(byId['1'].parentSdsId).toBeUndefined();
|
||||
expect(byId['11'].parentSdsId).toBe('1');
|
||||
expect(byId['12'].parentSdsId).toBe('1');
|
||||
expect(byId['121'].parentSdsId).toBe('12');
|
||||
});
|
||||
|
||||
it('skips nodes without a usable id', () => {
|
||||
const flat = service.flattenCategoryTree([{ id: null, name: 'no-id' }]);
|
||||
expect(flat).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncCategories', () => {
|
||||
it('inserts + updates rows and links parents', async () => {
|
||||
const stamp = Date.now();
|
||||
sds.fetchCategoryTree.mockResolvedValueOnce([
|
||||
{
|
||||
id: `r-${stamp}`,
|
||||
name: `Root ${stamp}`,
|
||||
children: [{ id: `c-${stamp}`, name: `Child ${stamp}` }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.syncCategories();
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.inserted).toBe(2);
|
||||
expect(result.updated).toBe(0);
|
||||
createdSdsCategoryIds.push(`r-${stamp}`, `c-${stamp}`);
|
||||
|
||||
const child = await prisma.category.findUnique({
|
||||
where: { sdsCategoryId: `c-${stamp}` },
|
||||
});
|
||||
const root = await prisma.category.findUnique({
|
||||
where: { sdsCategoryId: `r-${stamp}` },
|
||||
});
|
||||
expect(child?.parentCategoryId).toBe(root?.id);
|
||||
});
|
||||
|
||||
it('marks SyncLog SUCCESS', async () => {
|
||||
const logs = await prisma.syncLog.findMany({
|
||||
where: { type: 'CATEGORIES' },
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
expect(logs[0]?.status).toBe('SUCCESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProducts', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('upserts origin goods by sdsGoodId and updates on re-run', async () => {
|
||||
// Seed a leaf category with sdsCategoryId so the sync has work.
|
||||
const stamp = Date.now();
|
||||
const leaf = await prisma.category.create({
|
||||
data: {
|
||||
sdsCategoryId: `leaf-${stamp}`,
|
||||
categoryName: `Leaf ${stamp}`,
|
||||
},
|
||||
});
|
||||
createdSdsCategoryIds.push(`leaf-${stamp}`);
|
||||
|
||||
// Default mock returns a single page with 2 products, then
|
||||
// breaks the loop because content.length < 50.
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leaf-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `p-${stamp}-1`, name: 'Product 1', price: 12.5, pic: 'http://x' },
|
||||
{ id: `p-${stamp}-2`, name: 'Product 2', price: '99.00' },
|
||||
],
|
||||
};
|
||||
}
|
||||
// For any other (already-existing) category, return empty
|
||||
// so the loop terminates immediately.
|
||||
return { content: [] };
|
||||
});
|
||||
|
||||
const result1 = await service.syncProducts();
|
||||
expect(result1.inserted).toBeGreaterThanOrEqual(2);
|
||||
createdSdsGoodIds.push(`p-${stamp}-1`, `p-${stamp}-2`);
|
||||
|
||||
// Re-run with updated name -> should be `updated`, not `inserted`.
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leaf-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `p-${stamp}-1`, name: 'Product 1 renamed' },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: [] };
|
||||
});
|
||||
const result2 = await service.syncProducts();
|
||||
expect(result2.updated).toBeGreaterThanOrEqual(1);
|
||||
const row = await prisma.originGood.findUnique({
|
||||
where: { sdsGoodId: `p-${stamp}-1` },
|
||||
});
|
||||
expect(row?.goodName).toBe('Product 1 renamed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync guard thresholds', () => {
|
||||
describe('shouldSkipStaleDeletion', () => {
|
||||
it('skips stale deletion when the fetched count is below the hard floor', () => {
|
||||
expect(shouldSkipStaleDeletion(2, 226)).toBe(true);
|
||||
expect(shouldSkipStaleDeletion(9, 226)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips stale deletion when fetched is far smaller than existing (ratio guard)', () => {
|
||||
expect(shouldSkipStaleDeletion(100, 250)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT skip when fetched count is healthy', () => {
|
||||
expect(shouldSkipStaleDeletion(226, 226)).toBe(false);
|
||||
expect(shouldSkipStaleDeletion(200, 226)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT skip when there are no existing SDS categories', () => {
|
||||
expect(shouldSkipStaleDeletion(0, 0)).toBe(false);
|
||||
expect(shouldSkipStaleDeletion(2, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRunDelistDetection', () => {
|
||||
it('skips delist detection when leaf categories are too few', () => {
|
||||
expect(shouldRunDelistDetection(2, 500)).toBe(false);
|
||||
expect(shouldRunDelistDetection(9, 500)).toBe(false);
|
||||
});
|
||||
|
||||
it('skips delist detection when the seen product count is too small', () => {
|
||||
expect(shouldRunDelistDetection(148, 2)).toBe(false);
|
||||
expect(shouldRunDelistDetection(148, 49)).toBe(false);
|
||||
});
|
||||
|
||||
it('runs delist detection only when both metrics are healthy', () => {
|
||||
expect(shouldRunDelistDetection(148, 500)).toBe(true);
|
||||
expect(shouldRunDelistDetection(10, 50)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('category sync keeps existing SDS categories when upstream returns a degenerate tree', async () => {
|
||||
const stamp = Date.now();
|
||||
const keep = await prisma.category.create({
|
||||
data: { sdsCategoryId: `keep-${stamp}`, categoryName: `Keep ${stamp}` },
|
||||
});
|
||||
createdSdsCategoryIds.push(`keep-${stamp}`);
|
||||
|
||||
sds.fetchCategoryTree.mockResolvedValueOnce([
|
||||
{ id: `g-${stamp}-1`, name: 'Tiny 1' },
|
||||
{ id: `g-${stamp}-2`, name: 'Tiny 2' },
|
||||
]);
|
||||
createdSdsCategoryIds.push(`g-${stamp}-1`, `g-${stamp}-2`);
|
||||
|
||||
const result = await service.syncCategories();
|
||||
expect(result.deletedStale).toBe(0);
|
||||
|
||||
const still = await prisma.category.findUnique({ where: { id: keep.id } });
|
||||
expect(still).not.toBeNull();
|
||||
});
|
||||
|
||||
it('product sync does NOT delist origin goods when it sees too few products', async () => {
|
||||
const stamp = Date.now();
|
||||
const leaf = await prisma.category.create({
|
||||
data: { sdsCategoryId: `leafguard-${stamp}`, categoryName: `LeafGuard ${stamp}` },
|
||||
});
|
||||
createdSdsCategoryIds.push(`leafguard-${stamp}`);
|
||||
|
||||
const active = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `active-${stamp}`, delisted: false, goodName: 'Active' },
|
||||
});
|
||||
createdSdsGoodIds.push(`active-${stamp}`);
|
||||
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leafguard-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `guardp-${stamp}-1`, name: 'P1' },
|
||||
{ id: `guardp-${stamp}-2`, name: 'P2' },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: [] };
|
||||
});
|
||||
|
||||
const result = await service.syncProducts();
|
||||
expect(result.delisted).toBe(0);
|
||||
|
||||
const still = await prisma.originGood.findUnique({ where: { id: active.id } });
|
||||
expect(still?.delisted).toBe(false);
|
||||
createdSdsGoodIds.push(`guardp-${stamp}-1`, `guardp-${stamp}-2`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns recent logs ordered by startedAt desc', async () => {
|
||||
const logs = await service.getStatus(5);
|
||||
expect(Array.isArray(logs)).toBe(true);
|
||||
expect(logs.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdSdsCategoryIds.length) {
|
||||
await prisma.category.deleteMany({
|
||||
where: { sdsCategoryId: { in: createdSdsCategoryIds } },
|
||||
});
|
||||
}
|
||||
if (createdSdsGoodIds.length) {
|
||||
await prisma.originGood.deleteMany({
|
||||
where: { sdsGoodId: { in: createdSdsGoodIds } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('flattenCategoryTree', () => {
|
||||
it('flattens a nested SDS tree and preserves parent linkage', () => {
|
||||
const tree = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Root',
|
||||
children: [
|
||||
{ id: 11, name: 'Child A' },
|
||||
{ id: 12, name: 'Child B', children: [{ id: 121, name: 'Leaf' }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
const flat = service.flattenCategoryTree(tree);
|
||||
expect(flat).toHaveLength(4);
|
||||
const byId = Object.fromEntries(flat.map((n) => [n.sdsId, n]));
|
||||
expect(byId['1'].name).toBe('Root');
|
||||
expect(byId['1'].parentSdsId).toBeUndefined();
|
||||
expect(byId['11'].parentSdsId).toBe('1');
|
||||
expect(byId['12'].parentSdsId).toBe('1');
|
||||
expect(byId['121'].parentSdsId).toBe('12');
|
||||
});
|
||||
|
||||
it('skips nodes without a usable id', () => {
|
||||
const flat = service.flattenCategoryTree([{ id: null, name: 'no-id' }]);
|
||||
expect(flat).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncCategories', () => {
|
||||
it('inserts + updates rows and links parents', async () => {
|
||||
const stamp = Date.now();
|
||||
sds.fetchCategoryTree.mockResolvedValueOnce([
|
||||
{
|
||||
id: `r-${stamp}`,
|
||||
name: `Root ${stamp}`,
|
||||
children: [{ id: `c-${stamp}`, name: `Child ${stamp}` }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.syncCategories();
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.inserted).toBe(2);
|
||||
expect(result.updated).toBe(0);
|
||||
createdSdsCategoryIds.push(`r-${stamp}`, `c-${stamp}`);
|
||||
|
||||
const child = await prisma.category.findUnique({
|
||||
where: { sdsCategoryId: `c-${stamp}` },
|
||||
});
|
||||
const root = await prisma.category.findUnique({
|
||||
where: { sdsCategoryId: `r-${stamp}` },
|
||||
});
|
||||
expect(child?.parentCategoryId).toBe(root?.id);
|
||||
});
|
||||
|
||||
it('marks SyncLog SUCCESS', async () => {
|
||||
const logs = await prisma.syncLog.findMany({
|
||||
where: { type: 'CATEGORIES' },
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
expect(logs[0]?.status).toBe('SUCCESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProducts', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('upserts origin goods by sdsGoodId and updates on re-run', async () => {
|
||||
// Seed a leaf category with sdsCategoryId so the sync has work.
|
||||
const stamp = Date.now();
|
||||
const leaf = await prisma.category.create({
|
||||
data: {
|
||||
sdsCategoryId: `leaf-${stamp}`,
|
||||
categoryName: `Leaf ${stamp}`,
|
||||
},
|
||||
});
|
||||
createdSdsCategoryIds.push(`leaf-${stamp}`);
|
||||
|
||||
// Default mock returns a single page with 2 products, then
|
||||
// breaks the loop because content.length < 50.
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leaf-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `p-${stamp}-1`, name: 'Product 1', price: 12.5, pic: 'http://x' },
|
||||
{ id: `p-${stamp}-2`, name: 'Product 2', price: '99.00' },
|
||||
],
|
||||
};
|
||||
}
|
||||
// For any other (already-existing) category, return empty
|
||||
// so the loop terminates immediately.
|
||||
return { content: [] };
|
||||
});
|
||||
|
||||
const result1 = await service.syncProducts();
|
||||
expect(result1.inserted).toBeGreaterThanOrEqual(2);
|
||||
createdSdsGoodIds.push(`p-${stamp}-1`, `p-${stamp}-2`);
|
||||
|
||||
// Re-run with updated name -> should be `updated`, not `inserted`.
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leaf-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `p-${stamp}-1`, name: 'Product 1 renamed' },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: [] };
|
||||
});
|
||||
const result2 = await service.syncProducts();
|
||||
expect(result2.updated).toBeGreaterThanOrEqual(1);
|
||||
const row = await prisma.originGood.findUnique({
|
||||
where: { sdsGoodId: `p-${stamp}-1` },
|
||||
});
|
||||
expect(row?.goodName).toBe('Product 1 renamed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync guard thresholds', () => {
|
||||
describe('shouldSkipStaleDeletion', () => {
|
||||
it('skips stale deletion when the fetched count is below the hard floor', () => {
|
||||
expect(shouldSkipStaleDeletion(2, 226)).toBe(true);
|
||||
expect(shouldSkipStaleDeletion(9, 226)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips stale deletion when fetched is far smaller than existing (ratio guard)', () => {
|
||||
expect(shouldSkipStaleDeletion(100, 250)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT skip when fetched count is healthy', () => {
|
||||
expect(shouldSkipStaleDeletion(226, 226)).toBe(false);
|
||||
expect(shouldSkipStaleDeletion(200, 226)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT skip when there are no existing SDS categories', () => {
|
||||
expect(shouldSkipStaleDeletion(0, 0)).toBe(false);
|
||||
expect(shouldSkipStaleDeletion(2, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRunDelistDetection', () => {
|
||||
it('skips delist detection when leaf categories are too few', () => {
|
||||
expect(shouldRunDelistDetection(2, 500)).toBe(false);
|
||||
expect(shouldRunDelistDetection(9, 500)).toBe(false);
|
||||
});
|
||||
|
||||
it('skips delist detection when the seen product count is too small', () => {
|
||||
expect(shouldRunDelistDetection(148, 2)).toBe(false);
|
||||
expect(shouldRunDelistDetection(148, 49)).toBe(false);
|
||||
});
|
||||
|
||||
it('runs delist detection only when both metrics are healthy', () => {
|
||||
expect(shouldRunDelistDetection(148, 500)).toBe(true);
|
||||
expect(shouldRunDelistDetection(10, 50)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('category sync keeps existing SDS categories when upstream returns a degenerate tree', async () => {
|
||||
const stamp = Date.now();
|
||||
const keep = await prisma.category.create({
|
||||
data: { sdsCategoryId: `keep-${stamp}`, categoryName: `Keep ${stamp}` },
|
||||
});
|
||||
createdSdsCategoryIds.push(`keep-${stamp}`);
|
||||
|
||||
sds.fetchCategoryTree.mockResolvedValueOnce([
|
||||
{ id: `g-${stamp}-1`, name: 'Tiny 1' },
|
||||
{ id: `g-${stamp}-2`, name: 'Tiny 2' },
|
||||
]);
|
||||
createdSdsCategoryIds.push(`g-${stamp}-1`, `g-${stamp}-2`);
|
||||
|
||||
const result = await service.syncCategories();
|
||||
expect(result.deletedStale).toBe(0);
|
||||
|
||||
const still = await prisma.category.findUnique({ where: { id: keep.id } });
|
||||
expect(still).not.toBeNull();
|
||||
});
|
||||
|
||||
it('product sync does NOT delist origin goods when it sees too few products', async () => {
|
||||
const stamp = Date.now();
|
||||
const leaf = await prisma.category.create({
|
||||
data: { sdsCategoryId: `leafguard-${stamp}`, categoryName: `LeafGuard ${stamp}` },
|
||||
});
|
||||
createdSdsCategoryIds.push(`leafguard-${stamp}`);
|
||||
|
||||
const active = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `active-${stamp}`, delisted: false, goodName: 'Active' },
|
||||
});
|
||||
createdSdsGoodIds.push(`active-${stamp}`);
|
||||
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leafguard-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `guardp-${stamp}-1`, name: 'P1' },
|
||||
{ id: `guardp-${stamp}-2`, name: 'P2' },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: [] };
|
||||
});
|
||||
|
||||
const result = await service.syncProducts();
|
||||
expect(result.delisted).toBe(0);
|
||||
|
||||
const still = await prisma.originGood.findUnique({ where: { id: active.id } });
|
||||
expect(still?.delisted).toBe(false);
|
||||
createdSdsGoodIds.push(`guardp-${stamp}-1`, `guardp-${stamp}-2`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns recent logs ordered by startedAt desc', async () => {
|
||||
const logs = await service.getStatus(5);
|
||||
expect(Array.isArray(logs)).toBe(true);
|
||||
expect(logs.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncService product detail scopes', () => {
|
||||
|
||||
+743
-736
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTagGroupDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupIcon?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Hex color' })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
groupColor?: string;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTagGroupDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
groupName!: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupIcon?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Hex color' })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
groupColor?: string;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsInt, Min, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class TagGroupOrderItem {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder!: number;
|
||||
}
|
||||
|
||||
export class ReorderTagGroupsDto {
|
||||
@ApiProperty({ type: [TagGroupOrderItem] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TagGroupOrderItem)
|
||||
items!: TagGroupOrderItem[];
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsInt, Min, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class TagGroupOrderItem {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder!: number;
|
||||
}
|
||||
|
||||
export class ReorderTagGroupsDto {
|
||||
@ApiProperty({ type: [TagGroupOrderItem] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TagGroupOrderItem)
|
||||
items!: TagGroupOrderItem[];
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateTagGroupDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupIcon?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
groupColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateTagGroupDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupIcon?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
groupColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
|
||||
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
|
||||
import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto';
|
||||
|
||||
@ApiTags('tag-groups')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('tag-groups')
|
||||
export class TagGroupsController {
|
||||
constructor(private readonly service: TagGroupsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all tag groups' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one tag group' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
// IMPORTANT: must come BEFORE ':id' to avoid being captured as id
|
||||
@Patch('sort')
|
||||
@ApiOperation({ summary: 'Batch update sort order' })
|
||||
reorder(@Body() dto: ReorderTagGroupsDto) {
|
||||
return this.service.reorder(dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a tag group' })
|
||||
create(@Body() dto: CreateTagGroupDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a tag group' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateTagGroupDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a tag group (tags become ungrouped)' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
|
||||
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
|
||||
import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto';
|
||||
|
||||
@ApiTags('tag-groups')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('tag-groups')
|
||||
export class TagGroupsController {
|
||||
constructor(private readonly service: TagGroupsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all tag groups' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one tag group' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
// IMPORTANT: must come BEFORE ':id' to avoid being captured as id
|
||||
@Patch('sort')
|
||||
@ApiOperation({ summary: 'Batch update sort order' })
|
||||
reorder(@Body() dto: ReorderTagGroupsDto) {
|
||||
return this.service.reorder(dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a tag group' })
|
||||
create(@Body() dto: CreateTagGroupDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a tag group' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateTagGroupDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a tag group (tags become ungrouped)' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagGroupsController } from './tag-groups.controller';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagGroupsController],
|
||||
providers: [TagGroupsService],
|
||||
exports: [TagGroupsService],
|
||||
})
|
||||
export class TagGroupsModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagGroupsController } from './tag-groups.controller';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagGroupsController],
|
||||
providers: [TagGroupsService],
|
||||
exports: [TagGroupsService],
|
||||
})
|
||||
export class TagGroupsModule {}
|
||||
|
||||
@@ -1,109 +1,109 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('TagGroupsService', () => {
|
||||
let service: TagGroupsService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [TagGroupsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(TagGroupsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.tagGroup.deleteMany({ where: { groupName: { in: createdNames } } });
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates and finds a tag group', async () => {
|
||||
const name = `Group ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const created = await service.create({ groupName: name, sortOrder: 99 });
|
||||
expect(created.groupName).toBe(name);
|
||||
expect(created.sortOrder).toBe(99);
|
||||
|
||||
const found = await service.findOne(created.id);
|
||||
expect(found.groupName).toBe(name);
|
||||
});
|
||||
|
||||
it('rejects duplicate group names', async () => {
|
||||
const name = `Dup ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
await service.create({ groupName: name });
|
||||
await expect(service.create({ groupName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates group name and color', async () => {
|
||||
const name = `Upd ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const g = await service.create({ groupName: name });
|
||||
const updated = await service.update(g.id, {
|
||||
groupName: `${name}-v2`,
|
||||
groupColor: '#FF8800',
|
||||
});
|
||||
expect(updated.groupName).toBe(`${name}-v2`);
|
||||
expect(updated.groupColor).toBe('#FF8800');
|
||||
});
|
||||
|
||||
it('reorders multiple groups', async () => {
|
||||
const a = `Reorder A ${Date.now()}`;
|
||||
const b = `Reorder B ${Date.now()}`;
|
||||
createdNames.push(a, b);
|
||||
const ga = await service.create({ groupName: a, sortOrder: 1 });
|
||||
const gb = await service.create({ groupName: b, sortOrder: 2 });
|
||||
|
||||
await service.reorder({
|
||||
items: [
|
||||
{ id: Number(ga.id), sortOrder: 5 },
|
||||
{ id: Number(gb.id), sortOrder: 6 },
|
||||
],
|
||||
});
|
||||
|
||||
const refreshedA = await prisma.tagGroup.findUnique({ where: { id: ga.id } });
|
||||
const refreshedB = await prisma.tagGroup.findUnique({ where: { id: gb.id } });
|
||||
expect(refreshedA!.sortOrder).toBe(5);
|
||||
expect(refreshedB!.sortOrder).toBe(6);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for non-existent group', async () => {
|
||||
await expect(service.findOne(BigInt(999999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a group; tags become ungrouped (SetNull)', async () => {
|
||||
const name = `Del ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const g = await service.create({ groupName: name });
|
||||
|
||||
// Create a tag assigned to it
|
||||
const tagName = `TagInGroup ${Date.now()}`;
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName, tagGroupId: g.id },
|
||||
});
|
||||
|
||||
await service.remove(g.id);
|
||||
|
||||
const refreshedTag = await prisma.tag.findUnique({ where: { id: tag.id } });
|
||||
expect(refreshedTag).not.toBeNull();
|
||||
expect(refreshedTag!.tagGroupId).toBeNull();
|
||||
|
||||
// cleanup
|
||||
await prisma.tag.delete({ where: { id: tag.id } });
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { TagGroupsService } from './tag-groups.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('TagGroupsService', () => {
|
||||
let service: TagGroupsService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [TagGroupsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(TagGroupsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.tagGroup.deleteMany({ where: { groupName: { in: createdNames } } });
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates and finds a tag group', async () => {
|
||||
const name = `Group ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const created = await service.create({ groupName: name, sortOrder: 99 });
|
||||
expect(created.groupName).toBe(name);
|
||||
expect(created.sortOrder).toBe(99);
|
||||
|
||||
const found = await service.findOne(created.id);
|
||||
expect(found.groupName).toBe(name);
|
||||
});
|
||||
|
||||
it('rejects duplicate group names', async () => {
|
||||
const name = `Dup ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
await service.create({ groupName: name });
|
||||
await expect(service.create({ groupName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates group name and color', async () => {
|
||||
const name = `Upd ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const g = await service.create({ groupName: name });
|
||||
const updated = await service.update(g.id, {
|
||||
groupName: `${name}-v2`,
|
||||
groupColor: '#FF8800',
|
||||
});
|
||||
expect(updated.groupName).toBe(`${name}-v2`);
|
||||
expect(updated.groupColor).toBe('#FF8800');
|
||||
});
|
||||
|
||||
it('reorders multiple groups', async () => {
|
||||
const a = `Reorder A ${Date.now()}`;
|
||||
const b = `Reorder B ${Date.now()}`;
|
||||
createdNames.push(a, b);
|
||||
const ga = await service.create({ groupName: a, sortOrder: 1 });
|
||||
const gb = await service.create({ groupName: b, sortOrder: 2 });
|
||||
|
||||
await service.reorder({
|
||||
items: [
|
||||
{ id: Number(ga.id), sortOrder: 5 },
|
||||
{ id: Number(gb.id), sortOrder: 6 },
|
||||
],
|
||||
});
|
||||
|
||||
const refreshedA = await prisma.tagGroup.findUnique({ where: { id: ga.id } });
|
||||
const refreshedB = await prisma.tagGroup.findUnique({ where: { id: gb.id } });
|
||||
expect(refreshedA!.sortOrder).toBe(5);
|
||||
expect(refreshedB!.sortOrder).toBe(6);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for non-existent group', async () => {
|
||||
await expect(service.findOne(BigInt(999999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a group; tags become ungrouped (SetNull)', async () => {
|
||||
const name = `Del ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const g = await service.create({ groupName: name });
|
||||
|
||||
// Create a tag assigned to it
|
||||
const tagName = `TagInGroup ${Date.now()}`;
|
||||
const tag = await prisma.tag.create({
|
||||
data: { tagName, tagGroupId: g.id },
|
||||
});
|
||||
|
||||
await service.remove(g.id);
|
||||
|
||||
const refreshedTag = await prisma.tag.findUnique({ where: { id: tag.id } });
|
||||
expect(refreshedTag).not.toBeNull();
|
||||
expect(refreshedTag!.tagGroupId).toBeNull();
|
||||
|
||||
// cleanup
|
||||
await prisma.tag.delete({ where: { id: tag.id } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,88 +1,88 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
|
||||
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
|
||||
import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TagGroupsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.tagGroup.findMany({
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: { _count: { select: { tags: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const g = await this.prisma.tagGroup.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { tags: true } } },
|
||||
});
|
||||
if (!g) throw new NotFoundException(`TagGroup ${id} not found`);
|
||||
return g;
|
||||
}
|
||||
|
||||
async create(dto: CreateTagGroupDto) {
|
||||
try {
|
||||
return await this.prisma.tagGroup.create({
|
||||
data: {
|
||||
groupName: dto.groupName,
|
||||
groupIcon: dto.groupIcon ?? null,
|
||||
groupColor: dto.groupColor ?? null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag group name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateTagGroupDto) {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.TagGroupUpdateInput = {};
|
||||
if (dto.groupName !== undefined) data.groupName = dto.groupName;
|
||||
if (dto.groupIcon !== undefined) data.groupIcon = dto.groupIcon;
|
||||
if (dto.groupColor !== undefined) data.groupColor = dto.groupColor;
|
||||
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||
try {
|
||||
return await this.prisma.tagGroup.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag group name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.tagGroup.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderTagGroupsDto) {
|
||||
return this.prisma.$transaction(
|
||||
dto.items.map((item) =>
|
||||
this.prisma.tagGroup.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: { sortOrder: item.sortOrder },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
|
||||
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
|
||||
import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TagGroupsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.tagGroup.findMany({
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: { _count: { select: { tags: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const g = await this.prisma.tagGroup.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { tags: true } } },
|
||||
});
|
||||
if (!g) throw new NotFoundException(`TagGroup ${id} not found`);
|
||||
return g;
|
||||
}
|
||||
|
||||
async create(dto: CreateTagGroupDto) {
|
||||
try {
|
||||
return await this.prisma.tagGroup.create({
|
||||
data: {
|
||||
groupName: dto.groupName,
|
||||
groupIcon: dto.groupIcon ?? null,
|
||||
groupColor: dto.groupColor ?? null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag group name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateTagGroupDto) {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.TagGroupUpdateInput = {};
|
||||
if (dto.groupName !== undefined) data.groupName = dto.groupName;
|
||||
if (dto.groupIcon !== undefined) data.groupIcon = dto.groupIcon;
|
||||
if (dto.groupColor !== undefined) data.groupColor = dto.groupColor;
|
||||
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||
try {
|
||||
return await this.prisma.tagGroup.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag group name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.tagGroup.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderTagGroupsDto) {
|
||||
return this.prisma.$transaction(
|
||||
dto.items.map((item) =>
|
||||
this.prisma.tagGroup.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: { sortOrder: item.sortOrder },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTagDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
tagName!: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
nullable: true,
|
||||
description: 'Hex color, e.g. #FF0000',
|
||||
example: '#FF8800',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagColor?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
nullable: true,
|
||||
description: 'Hex font color, e.g. #FFFFFF',
|
||||
example: '#FFFFFF',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagFontColor?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timing?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Tag group ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateTagDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
tagName!: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
nullable: true,
|
||||
description: 'Hex color, e.g. #FF0000',
|
||||
example: '#FF8800',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagColor?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
nullable: true,
|
||||
description: 'Hex font color, e.g. #FFFFFF',
|
||||
example: '#FFFFFF',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagFontColor?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timing?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Tag group ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class TagOrderItem {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Move tag to this group (null = ungrouped)' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder!: number;
|
||||
}
|
||||
|
||||
export class ReorderTagsDto {
|
||||
@ApiProperty({ type: [TagOrderItem] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TagOrderItem)
|
||||
items!: TagOrderItem[];
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class TagOrderItem {
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Move tag to this group (null = ungrouped)' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder!: number;
|
||||
}
|
||||
|
||||
export class ReorderTagsDto {
|
||||
@ApiProperty({ type: [TagOrderItem] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TagOrderItem)
|
||||
items!: TagOrderItem[];
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateTagDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tagName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagFontColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timing?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Tag group ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsHexColor,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateTagDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tagName?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
tagFontColor?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timing?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, description: 'Tag group ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
tagGroupId?: number | null;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TagsService } from './tags.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
import { UpdateTagDto } from './dto/update-tag.dto';
|
||||
import { ReorderTagsDto } from './dto/reorder-tags.dto';
|
||||
|
||||
@ApiTags('tags')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('tags')
|
||||
export class TagsController {
|
||||
constructor(private readonly service: TagsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all tags' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one tag' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a tag' })
|
||||
create(@Body() dto: CreateTagDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
// IMPORTANT: must come BEFORE ':id' to avoid being captured as id
|
||||
@Patch('sort')
|
||||
@ApiOperation({ summary: 'Batch update sort order and/or group assignment' })
|
||||
reorder(@Body() dto: ReorderTagsDto) {
|
||||
return this.service.reorder(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a tag' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateTagDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a tag' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { TagsService } from './tags.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
import { UpdateTagDto } from './dto/update-tag.dto';
|
||||
import { ReorderTagsDto } from './dto/reorder-tags.dto';
|
||||
|
||||
@ApiTags('tags')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('tags')
|
||||
export class TagsController {
|
||||
constructor(private readonly service: TagsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all tags' })
|
||||
findAll() {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one tag' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a tag' })
|
||||
create(@Body() dto: CreateTagDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
// IMPORTANT: must come BEFORE ':id' to avoid being captured as id
|
||||
@Patch('sort')
|
||||
@ApiOperation({ summary: 'Batch update sort order and/or group assignment' })
|
||||
reorder(@Body() dto: ReorderTagsDto) {
|
||||
return this.service.reorder(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a tag' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateTagDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a tag' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagsController } from './tags.controller';
|
||||
import { TagsService } from './tags.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagsController],
|
||||
providers: [TagsService],
|
||||
exports: [TagsService],
|
||||
})
|
||||
export class TagsModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagsController } from './tags.controller';
|
||||
import { TagsService } from './tags.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagsController],
|
||||
providers: [TagsService],
|
||||
exports: [TagsService],
|
||||
})
|
||||
export class TagsModule {}
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { TagsService } from './tags.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
|
||||
describe('TagsService', () => {
|
||||
let service: TagsService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [TagsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(TagsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.tag.deleteMany({ where: { tagName: { in: createdNames } } });
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates a tag and validates hex color via DTO', async () => {
|
||||
const name = `Tag ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const created = await service.create({ tagName: name, tagColor: '#FF8800' });
|
||||
expect(created.tagColor).toBe('#FF8800');
|
||||
|
||||
const invalid = plainToInstance(CreateTagDto, {
|
||||
tagName: 'invalid',
|
||||
tagColor: 'not-a-color',
|
||||
});
|
||||
const errors = await validate(invalid);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe('tagColor');
|
||||
});
|
||||
|
||||
it('rejects duplicate tag names with ConflictException', async () => {
|
||||
const name = `Dup ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
await service.create({ tagName: name });
|
||||
await expect(service.create({ tagName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates and deletes', async () => {
|
||||
const name = `Upd ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const tag = await service.create({ tagName: name });
|
||||
const updated = await service.update(tag.id, { tagName: `${name}-v2` });
|
||||
expect(updated.tagName).toBe(`${name}-v2`);
|
||||
await service.remove(tag.id);
|
||||
const idx = createdNames.indexOf(name);
|
||||
if (idx !== -1) createdNames[idx] = `${name}-v2`;
|
||||
});
|
||||
|
||||
it('assigns tagGroupId and sortOrder on create, supports reordering', async () => {
|
||||
// Use existing seeded groups
|
||||
const group = await prisma.tagGroup.findFirst({
|
||||
where: { groupName: '物流渠道' },
|
||||
});
|
||||
expect(group).not.toBeNull();
|
||||
|
||||
const a = `Grouped A ${Date.now()}`;
|
||||
const b = `Grouped B ${Date.now()}`;
|
||||
createdNames.push(a, b);
|
||||
|
||||
const tagA = await service.create({
|
||||
tagName: a,
|
||||
tagGroupId: Number(group!.id),
|
||||
sortOrder: 1,
|
||||
});
|
||||
expect(tagA.tagGroupId).toBe(group!.id);
|
||||
|
||||
const tagB = await service.create({
|
||||
tagName: b,
|
||||
tagGroupId: Number(group!.id),
|
||||
sortOrder: 2,
|
||||
});
|
||||
expect(tagB.sortOrder).toBe(2);
|
||||
|
||||
// Reorder swap
|
||||
await service.reorder({
|
||||
items: [
|
||||
{ id: Number(tagA.id), sortOrder: 2 },
|
||||
{ id: Number(tagB.id), sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const refreshedA = await prisma.tag.findUnique({ where: { id: tagA.id } });
|
||||
const refreshedB = await prisma.tag.findUnique({ where: { id: tagB.id } });
|
||||
expect(refreshedA!.sortOrder).toBe(2);
|
||||
expect(refreshedB!.sortOrder).toBe(1);
|
||||
});
|
||||
|
||||
it('removes a tag cleanly even when it is in a group', async () => {
|
||||
const group = await prisma.tagGroup.findFirst({
|
||||
where: { groupName: '印刷工艺' },
|
||||
});
|
||||
const name = `Grouped ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
|
||||
const tag = await service.create({
|
||||
tagName: name,
|
||||
tagGroupId: Number(group!.id),
|
||||
});
|
||||
await service.remove(tag.id);
|
||||
const found = await prisma.tag.findUnique({ where: { id: tag.id } });
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { TagsService } from './tags.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
|
||||
describe('TagsService', () => {
|
||||
let service: TagsService;
|
||||
let prisma: PrismaService;
|
||||
const createdNames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [TagsService, PrismaService],
|
||||
}).compile();
|
||||
service = moduleRef.get(TagsService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdNames.length) {
|
||||
await prisma.tag.deleteMany({ where: { tagName: { in: createdNames } } });
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates a tag and validates hex color via DTO', async () => {
|
||||
const name = `Tag ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const created = await service.create({ tagName: name, tagColor: '#FF8800' });
|
||||
expect(created.tagColor).toBe('#FF8800');
|
||||
|
||||
const invalid = plainToInstance(CreateTagDto, {
|
||||
tagName: 'invalid',
|
||||
tagColor: 'not-a-color',
|
||||
});
|
||||
const errors = await validate(invalid);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe('tagColor');
|
||||
});
|
||||
|
||||
it('rejects duplicate tag names with ConflictException', async () => {
|
||||
const name = `Dup ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
await service.create({ tagName: name });
|
||||
await expect(service.create({ tagName: name })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('updates and deletes', async () => {
|
||||
const name = `Upd ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
const tag = await service.create({ tagName: name });
|
||||
const updated = await service.update(tag.id, { tagName: `${name}-v2` });
|
||||
expect(updated.tagName).toBe(`${name}-v2`);
|
||||
await service.remove(tag.id);
|
||||
const idx = createdNames.indexOf(name);
|
||||
if (idx !== -1) createdNames[idx] = `${name}-v2`;
|
||||
});
|
||||
|
||||
it('assigns tagGroupId and sortOrder on create, supports reordering', async () => {
|
||||
// Use existing seeded groups
|
||||
const group = await prisma.tagGroup.findFirst({
|
||||
where: { groupName: '物流渠道' },
|
||||
});
|
||||
expect(group).not.toBeNull();
|
||||
|
||||
const a = `Grouped A ${Date.now()}`;
|
||||
const b = `Grouped B ${Date.now()}`;
|
||||
createdNames.push(a, b);
|
||||
|
||||
const tagA = await service.create({
|
||||
tagName: a,
|
||||
tagGroupId: Number(group!.id),
|
||||
sortOrder: 1,
|
||||
});
|
||||
expect(tagA.tagGroupId).toBe(group!.id);
|
||||
|
||||
const tagB = await service.create({
|
||||
tagName: b,
|
||||
tagGroupId: Number(group!.id),
|
||||
sortOrder: 2,
|
||||
});
|
||||
expect(tagB.sortOrder).toBe(2);
|
||||
|
||||
// Reorder swap
|
||||
await service.reorder({
|
||||
items: [
|
||||
{ id: Number(tagA.id), sortOrder: 2 },
|
||||
{ id: Number(tagB.id), sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const refreshedA = await prisma.tag.findUnique({ where: { id: tagA.id } });
|
||||
const refreshedB = await prisma.tag.findUnique({ where: { id: tagB.id } });
|
||||
expect(refreshedA!.sortOrder).toBe(2);
|
||||
expect(refreshedB!.sortOrder).toBe(1);
|
||||
});
|
||||
|
||||
it('removes a tag cleanly even when it is in a group', async () => {
|
||||
const group = await prisma.tagGroup.findFirst({
|
||||
where: { groupName: '印刷工艺' },
|
||||
});
|
||||
const name = `Grouped ${Date.now()}`;
|
||||
createdNames.push(name);
|
||||
|
||||
const tag = await service.create({
|
||||
tagName: name,
|
||||
tagGroupId: Number(group!.id),
|
||||
});
|
||||
await service.remove(tag.id);
|
||||
const found = await prisma.tag.findUnique({ where: { id: tag.id } });
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+104
-104
@@ -1,104 +1,104 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
import { UpdateTagDto } from './dto/update-tag.dto';
|
||||
import { ReorderTagsDto } from './dto/reorder-tags.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TagsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.tag.findMany({
|
||||
orderBy: [{ tagGroupId: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: { tagGroup: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const t = await this.prisma.tag.findUnique({
|
||||
where: { id },
|
||||
include: { tagGroup: true },
|
||||
});
|
||||
if (!t) throw new NotFoundException(`Tag ${id} not found`);
|
||||
return t;
|
||||
}
|
||||
|
||||
async create(dto: CreateTagDto) {
|
||||
try {
|
||||
return await this.prisma.tag.create({
|
||||
data: {
|
||||
tagName: dto.tagName,
|
||||
tagColor: dto.tagColor ?? null,
|
||||
tagFontColor: dto.tagFontColor ?? null,
|
||||
timing: dto.timing ?? null,
|
||||
tagGroupId: dto.tagGroupId ? BigInt(dto.tagGroupId) : null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateTagDto) {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.TagUpdateInput = {};
|
||||
if (dto.tagName !== undefined) data.tagName = dto.tagName;
|
||||
if (dto.tagColor !== undefined) data.tagColor = dto.tagColor;
|
||||
if (dto.tagFontColor !== undefined) data.tagFontColor = dto.tagFontColor;
|
||||
if (dto.timing !== undefined) data.timing = dto.timing;
|
||||
if (dto.tagGroupId !== undefined) {
|
||||
data.tagGroup = dto.tagGroupId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.tagGroupId) } };
|
||||
}
|
||||
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||
try {
|
||||
return await this.prisma.tag.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.tag.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderTagsDto) {
|
||||
return this.prisma.$transaction(
|
||||
dto.items.map((item) =>
|
||||
this.prisma.tag.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: {
|
||||
sortOrder: item.sortOrder,
|
||||
tagGroupId:
|
||||
item.tagGroupId === undefined
|
||||
? undefined
|
||||
: item.tagGroupId === null
|
||||
? null
|
||||
: BigInt(item.tagGroupId),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTagDto } from './dto/create-tag.dto';
|
||||
import { UpdateTagDto } from './dto/update-tag.dto';
|
||||
import { ReorderTagsDto } from './dto/reorder-tags.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TagsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findAll() {
|
||||
return this.prisma.tag.findMany({
|
||||
orderBy: [{ tagGroupId: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
include: { tagGroup: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: bigint) {
|
||||
const t = await this.prisma.tag.findUnique({
|
||||
where: { id },
|
||||
include: { tagGroup: true },
|
||||
});
|
||||
if (!t) throw new NotFoundException(`Tag ${id} not found`);
|
||||
return t;
|
||||
}
|
||||
|
||||
async create(dto: CreateTagDto) {
|
||||
try {
|
||||
return await this.prisma.tag.create({
|
||||
data: {
|
||||
tagName: dto.tagName,
|
||||
tagColor: dto.tagColor ?? null,
|
||||
tagFontColor: dto.tagFontColor ?? null,
|
||||
timing: dto.timing ?? null,
|
||||
tagGroupId: dto.tagGroupId ? BigInt(dto.tagGroupId) : null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateTagDto) {
|
||||
await this.findOne(id);
|
||||
const data: Prisma.TagUpdateInput = {};
|
||||
if (dto.tagName !== undefined) data.tagName = dto.tagName;
|
||||
if (dto.tagColor !== undefined) data.tagColor = dto.tagColor;
|
||||
if (dto.tagFontColor !== undefined) data.tagFontColor = dto.tagFontColor;
|
||||
if (dto.timing !== undefined) data.timing = dto.timing;
|
||||
if (dto.tagGroupId !== undefined) {
|
||||
data.tagGroup = dto.tagGroupId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.tagGroupId) } };
|
||||
}
|
||||
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||
try {
|
||||
return await this.prisma.tag.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Tag name already exists');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.findOne(id);
|
||||
return this.prisma.tag.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async reorder(dto: ReorderTagsDto) {
|
||||
return this.prisma.$transaction(
|
||||
dto.items.map((item) =>
|
||||
this.prisma.tag.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: {
|
||||
sortOrder: item.sortOrder,
|
||||
tagGroupId:
|
||||
item.tagGroupId === undefined
|
||||
? undefined
|
||||
: item.tagGroupId === null
|
||||
? null
|
||||
: BigInt(item.tagGroupId),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
const UPLOAD_DIR = join(process.cwd(), 'uploads');
|
||||
|
||||
// Explicit safe-image whitelist. SVG is deliberately excluded: it can
|
||||
// carry scripts and is served from the same origin (stored XSS).
|
||||
const ALLOWED_EXTENSIONS = /\.(png|jpe?g|webp|gif)$/i;
|
||||
const ALLOWED_MIMETYPES = /^image\/(png|jpe?g|webp|gif)$/i;
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('upload')
|
||||
export class UploadController {
|
||||
@Post('image')
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
destination: UPLOAD_DIR,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = ALLOWED_EXTENSIONS.test(extname(file.originalname))
|
||||
? extname(file.originalname).toLowerCase()
|
||||
: '.png';
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!ALLOWED_EXTENSIONS.test(file.originalname) || !ALLOWED_MIMETYPES.test(file.mimetype)) {
|
||||
return cb(new BadRequestException('仅支持 png/jpg/webp/gif 图片'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
uploadImage(@UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('请选择要上传的文件');
|
||||
}
|
||||
return { url: `/uploads/${file.filename}`, filename: file.filename };
|
||||
}
|
||||
}
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
const UPLOAD_DIR = join(process.cwd(), 'uploads');
|
||||
|
||||
// Explicit safe-image whitelist. SVG is deliberately excluded: it can
|
||||
// carry scripts and is served from the same origin (stored XSS).
|
||||
const ALLOWED_EXTENSIONS = /\.(png|jpe?g|webp|gif)$/i;
|
||||
const ALLOWED_MIMETYPES = /^image\/(png|jpe?g|webp|gif)$/i;
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('upload')
|
||||
export class UploadController {
|
||||
@Post('image')
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
destination: UPLOAD_DIR,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = ALLOWED_EXTENSIONS.test(extname(file.originalname))
|
||||
? extname(file.originalname).toLowerCase()
|
||||
: '.png';
|
||||
cb(null, `${randomUUID()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!ALLOWED_EXTENSIONS.test(file.originalname) || !ALLOWED_MIMETYPES.test(file.mimetype)) {
|
||||
return cb(new BadRequestException('仅支持 png/jpg/webp/gif 图片'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
uploadImage(@UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('请选择要上传的文件');
|
||||
}
|
||||
return { url: `/uploads/${file.filename}`, filename: file.filename };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UploadController } from './upload.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [UploadController],
|
||||
})
|
||||
export class UploadModule {}
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UploadController } from './upload.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [UploadController],
|
||||
})
|
||||
export class UploadModule {}
|
||||
|
||||
Reference in New Issue
Block a user