chore: migrate to pnpm workspaces monorepo with Turborepo
- Restructure directories: apps/api, apps/admin, apps/website - Add root pnpm-workspace.yaml, turbo.json, .prettierrc, .gitignore - Rename packages to @inkreach/api, @inkreach/admin, @inkreach/website - Add shared packages: packages/tsconfig, packages/shared-types - Add pnpm.onlyBuiltDependencies for native builds - Update docs: README.md, structs.md - All three projects build successfully
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CountriesModule } from './countries/countries.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { TagGroupsModule } from './tag-groups/tag-groups.module';
|
||||
import { PositionsModule } from './positions/positions.module';
|
||||
import { OriginGoodsModule } from './origin-goods/origin-goods.module';
|
||||
import { GoodsModule } from './goods/goods.module';
|
||||
import { SyncModule } from './sync/sync.module';
|
||||
import { PublicModule } from './public/public.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
CountriesModule,
|
||||
CategoriesModule,
|
||||
TagsModule,
|
||||
TagGroupsModule,
|
||||
PositionsModule,
|
||||
OriginGoodsModule,
|
||||
GoodsModule,
|
||||
SyncModule,
|
||||
PublicModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import {
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import {
|
||||
LoginResponseDto,
|
||||
UserPublicDto,
|
||||
} from './dto/auth-response.dto';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Register a new admin user' })
|
||||
@ApiResponse({ status: 201, type: UserPublicDto })
|
||||
@ApiResponse({ status: 409, description: 'Username already exists' })
|
||||
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
|
||||
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Login and obtain a JWT' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||
login(@Body() dto: LoginDto): Promise<LoginResponseDto> {
|
||||
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => {
|
||||
const secret = config.get<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET must be configured');
|
||||
}
|
||||
return {
|
||||
secret,
|
||||
signOptions: { expiresIn: '7d' },
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let prisma: PrismaService;
|
||||
const createdUsernames: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
JwtModule.register({
|
||||
secret: 'test-secret',
|
||||
signOptions: { expiresIn: '1h' },
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, PrismaService],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(AuthService);
|
||||
prisma = moduleRef.get(PrismaService);
|
||||
await prisma.onModuleInit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup created test users
|
||||
if (createdUsernames.length) {
|
||||
await prisma.user.deleteMany({
|
||||
where: { username: { in: createdUsernames } },
|
||||
});
|
||||
}
|
||||
await prisma.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('creates a new user and stores a hashed password', async () => {
|
||||
const username = `test_reg_${Date.now()}`;
|
||||
createdUsernames.push(username);
|
||||
|
||||
const user = await service.register({ username, password: 'plain-pwd' });
|
||||
|
||||
expect(user.username).toBe(username);
|
||||
expect(user.id).toBeTruthy();
|
||||
|
||||
const stored = await prisma.user.findUnique({ where: { username } });
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.passwordHash).not.toBe('plain-pwd');
|
||||
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash);
|
||||
expect(matches).toBe(true);
|
||||
});
|
||||
|
||||
it('throws ConflictException for duplicate usernames', async () => {
|
||||
const username = `test_dup_${Date.now()}`;
|
||||
createdUsernames.push(username);
|
||||
|
||||
await service.register({ username, password: 'pwd1234' });
|
||||
await expect(
|
||||
service.register({ username, password: 'pwd5678' }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('returns an access token for valid credentials', async () => {
|
||||
const username = `test_login_${Date.now()}`;
|
||||
createdUsernames.push(username);
|
||||
await service.register({ username, password: 'correct-pwd' });
|
||||
|
||||
const result = await service.login({ username, password: 'correct-pwd' });
|
||||
expect(result.accessToken).toEqual(expect.any(String));
|
||||
const parts = result.accessToken.split('.');
|
||||
expect(parts.length).toBe(3);
|
||||
expect(result.user.username).toBe(username);
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException for wrong password', async () => {
|
||||
const username = `test_wrong_${Date.now()}`;
|
||||
createdUsernames.push(username);
|
||||
await service.register({ username, password: 'right-pwd' });
|
||||
|
||||
await expect(
|
||||
service.login({ username, password: 'wrong-pwd' }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException for unknown user', async () => {
|
||||
await expect(
|
||||
service.login({ username: 'no-such-user-xyz', password: 'whatever' }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import type { JwtPayload } from './strategies/jwt.strategy';
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
const TOKEN_EXPIRES_IN = '7d';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registers a brand-new admin user. Throws {@link ConflictException}
|
||||
* if the username is already taken.
|
||||
*/
|
||||
async register(dto: RegisterDto): Promise<PublicUser> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { username: dto.username },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Username already exists');
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
|
||||
const created = await this.prisma.user.create({
|
||||
data: { username: dto.username, passwordHash },
|
||||
});
|
||||
return this.toPublic(created);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies credentials and returns a signed JWT.
|
||||
*/
|
||||
async login(dto: LoginDto): Promise<LoginResult> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { username: dto.username },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id.toString(),
|
||||
username: user.username,
|
||||
};
|
||||
const accessToken = await this.jwt.signAsync(payload, {
|
||||
expiresIn: TOKEN_EXPIRES_IN,
|
||||
});
|
||||
return { accessToken, user: this.toPublic(user) };
|
||||
}
|
||||
|
||||
private toPublic(user: {
|
||||
id: bigint;
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
}): PublicUser {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UserPublicDto {
|
||||
@ApiProperty({ description: 'User ID (bigint serialized as string)' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
username!: string;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiProperty()
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty({ type: UserPublicDto })
|
||||
user!: UserPublicDto;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Payload accepted by `POST /auth/login`.
|
||||
*/
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Payload accepted by `POST /auth/register`.
|
||||
*
|
||||
* Username must be unique (enforced by DB); password is hashed with bcrypt.
|
||||
*/
|
||||
export class RegisterDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Default guard for every admin route. Delegates to passport-jwt.
|
||||
*
|
||||
* Apply with `@UseGuards(JwtAuthGuard)`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* Shape of the JWT we issue.
|
||||
*
|
||||
* `sub` is the user ID as a string (bigints are serialized to strings in JSON).
|
||||
*/
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
const secret = config.get<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET is not configured');
|
||||
}
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: secret,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs on every authenticated request. The returned object becomes
|
||||
* `request.user` for downstream controllers.
|
||||
*/
|
||||
validate(payload: JwtPayload): { id: bigint; username: string } {
|
||||
if (!payload?.sub || !payload.username) {
|
||||
throw new UnauthorizedException('Invalid token payload');
|
||||
}
|
||||
return { id: BigInt(payload.sub), username: payload.username };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 {}
|
||||
@@ -0,0 +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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
|
||||
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) {
|
||||
message = exception.message;
|
||||
error = exception.name;
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 {}
|
||||
@@ -0,0 +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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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[];
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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;
|
||||
sdsGoodId: string;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: unknown;
|
||||
} | 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;
|
||||
sdsGoodId: string;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: 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,
|
||||
originGood: rel.originGood
|
||||
? {
|
||||
id: rel.originGood.id.toString(),
|
||||
sdsGoodId: rel.originGood.sdsGoodId,
|
||||
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(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaginatedGoods {
|
||||
items: GoodDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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';
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one good with relations' })
|
||||
findOne(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.findOne(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a good' })
|
||||
create(@Body() dto: CreateGoodDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a good' })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: string,
|
||||
@Body() dto: UpdateGoodDto,
|
||||
) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a good' })
|
||||
remove(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
|
||||
@Patch('batch-priority')
|
||||
@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)' })
|
||||
batchCreate(@Body() dto: BatchCreateGoodDto) {
|
||||
return this.service.batchCreate(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GoodsController } from './goods.controller';
|
||||
import { GoodsService } from './goods.service';
|
||||
|
||||
@Module({
|
||||
controllers: [GoodsController],
|
||||
providers: [GoodsService],
|
||||
exports: [GoodsService],
|
||||
})
|
||||
export class GoodsModule {}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { GoodsService } from './goods.service';
|
||||
import { PrismaService } from '../prisma/prisma.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({
|
||||
providers: [GoodsService, PrismaService],
|
||||
}).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`);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
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 { GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||
|
||||
const GOOD_INCLUDE = {
|
||||
country: true,
|
||||
category: true,
|
||||
tag: true,
|
||||
position: true,
|
||||
originGood: true,
|
||||
goodTags: { include: { tag: true } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
@Injectable()
|
||||
export class GoodsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
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<GoodDto> {
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
include: GOOD_INCLUDE,
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
return GoodDto.from(good, {
|
||||
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);
|
||||
return 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,
|
||||
});
|
||||
return GoodDto.from(result, {
|
||||
country: result.country,
|
||||
category: result.category,
|
||||
tag: result.tag,
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return 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,
|
||||
});
|
||||
return GoodDto.from(updated, {
|
||||
country: updated.country,
|
||||
category: updated.category,
|
||||
tag: updated.tag,
|
||||
position: updated.position,
|
||||
originGood: updated.originGood,
|
||||
goodTags: updated.goodTags,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: bigint): Promise<{ id: string }> {
|
||||
await this.findOne(id);
|
||||
await this.prisma.good.delete({ where: { id } });
|
||||
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 this.prisma.$transaction(async (tx) => {
|
||||
for (const item of dto.items) {
|
||||
await tx.good.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: { goodPriority: item.priority },
|
||||
});
|
||||
}
|
||||
return { count: dto.items.length };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
return 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,
|
||||
}));
|
||||
}
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { json } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { bodyParser: false });
|
||||
|
||||
// Replace Express's JSON parser with one that stringifies BigInt.
|
||||
// Express's default `json()` throws "Do not know how to serialize a BigInt".
|
||||
app.use(
|
||||
json({
|
||||
limit: '2mb',
|
||||
reviver: (_key: string, value: unknown) => {
|
||||
// If a numeric string would overflow the JS number range we keep
|
||||
// it as a string; the JWT strategy / services expect bigints.
|
||||
if (typeof value === 'string' && /^-?\d{16,}$/.test(value)) {
|
||||
// Leave as string — services parse with BigInt().
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// CORS
|
||||
app.enableCors({
|
||||
origin: ['http://localhost:5173', 'http://localhost:3000'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Global pipes
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Global filters and interceptors
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor());
|
||||
|
||||
// Swagger
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('InkReach Product Center API')
|
||||
.setDescription('Backend API for InkReach Product Center')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = process.env.PORT ?? 3001;
|
||||
await app.listen(port);
|
||||
console.log(`🚀 Application is running on: http://localhost:${port}`);
|
||||
console.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
|
||||
// Make JSON.stringify aware of BigInt so outgoing responses containing
|
||||
// primary keys (`BigInt` columns) don't blow up. BigInts are serialized
|
||||
// as their decimal string — clients should parse them with BigInt().
|
||||
(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () {
|
||||
return this.toString();
|
||||
};
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 {}
|
||||
@@ -0,0 +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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
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;
|
||||
}>;
|
||||
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;
|
||||
configuredCount: number;
|
||||
configuredCountries: string[];
|
||||
configuredTags: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: 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;
|
||||
const where: Prisma.OriginGoodWhereInput = keyword
|
||||
? { goodName: { contains: keyword, mode: 'insensitive' } }
|
||||
: {};
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.originGood.count({ where }),
|
||||
this.prisma.originGood.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
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(),
|
||||
})),
|
||||
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({ orderBy: { goodName: 'asc' } }),
|
||||
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);
|
||||
|
||||
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,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
configuredTags: tagMap.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);
|
||||
|
||||
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,
|
||||
configuredCount: countMap.get(og.id.toString()) ?? 0,
|
||||
configuredCountries: countryMap.get(og.id.toString()) ?? [],
|
||||
configuredTags: tagMap.get(og.id.toString()) ?? [],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 {}
|
||||
@@ -0,0 +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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 {}
|
||||
@@ -0,0 +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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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[];
|
||||
|
||||
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,
|
||||
children,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PublicGoodDto {
|
||||
@ApiProperty()
|
||||
id!: 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;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class PublicQueryGoodDto {
|
||||
@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 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
categoryId?: number;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Comma-separated tag IDs, e.g. "30,34"' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tagIds?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
keyword?: string;
|
||||
}
|
||||
@@ -0,0 +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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { PublicService } from './public.service';
|
||||
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
|
||||
|
||||
@ApiTags('public')
|
||||
@Controller('public')
|
||||
export class PublicController {
|
||||
constructor(private readonly service: PublicService) {}
|
||||
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: 'Public list of categories that have goods' })
|
||||
getCategories() {
|
||||
return this.service.getCategoriesTree();
|
||||
}
|
||||
|
||||
@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: 'Public list of tag groups that have goods' })
|
||||
getTagGroups(): Promise<PublicTagGroupDto[]> {
|
||||
return this.service.getTagGroups();
|
||||
}
|
||||
|
||||
@Get('goods')
|
||||
@ApiOperation({ summary: 'Public paginated goods with filters' })
|
||||
getGoods(@Query() query: PublicQueryGoodDto) {
|
||||
return this.service.getGoods(query);
|
||||
}
|
||||
|
||||
@Get('goods/:id')
|
||||
@ApiOperation({ summary: 'Public good detail' })
|
||||
getGood(@Param('id', ParseIntPipe) id: string) {
|
||||
return this.service.getGood(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 {}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { 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;
|
||||
let tagId: 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,
|
||||
},
|
||||
});
|
||||
goodIds = [g1.id, g2.id, g3.id];
|
||||
|
||||
// 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.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: Number(countryId),
|
||||
categoryId: Number(categoryId), // 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);
|
||||
});
|
||||
|
||||
it('sorts by priority DESC, position.indexVal ASC, createdAt DESC', async () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: Number(countryId),
|
||||
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('getGood returns detail and 404 for unknown id', async () => {
|
||||
const first = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
countryId: Number(countryId),
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(first.items.length).toBe(1);
|
||||
const detail = await service.getGood(BigInt(first.items[0].id));
|
||||
expect(detail.id).toBe(first.items[0].id);
|
||||
|
||||
await expect(service.getGood(BigInt(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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
|
||||
import { PublicCategoryNodeDto } from './dto/public-category.dto';
|
||||
import { PublicCountryDto } from './dto/public-country.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
|
||||
import { PublicGoodDto } from './dto/public-good.dto';
|
||||
|
||||
export interface PublicPaginatedGoods {
|
||||
items: PublicGoodDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const PUBLIC_GOOD_INCLUDE = {
|
||||
country: true,
|
||||
category: true,
|
||||
tag: { include: { tagGroup: true } },
|
||||
position: true,
|
||||
originGood: true,
|
||||
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
@Injectable()
|
||||
export class PublicService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getCategoriesTree(): Promise<PublicCategoryNodeDto[]> {
|
||||
const leafCategories = await this.prisma.category.findMany({
|
||||
where: { goods: { some: {} } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const ancestorIds = new Set<bigint>();
|
||||
for (const leaf of leafCategories) {
|
||||
let cursor: bigint | null = leaf.parentCategoryId;
|
||||
while (cursor !== null && !ancestorIds.has(cursor)) {
|
||||
ancestorIds.add(cursor);
|
||||
const parent = await this.prisma.category.findUnique({
|
||||
where: { id: cursor },
|
||||
select: { id: true, parentCategoryId: true },
|
||||
});
|
||||
if (!parent) break;
|
||||
cursor = parent.parentCategoryId;
|
||||
}
|
||||
}
|
||||
const ancestorRows = ancestorIds.size > 0
|
||||
? await this.prisma.category.findMany({
|
||||
where: { id: { in: [...ancestorIds] } },
|
||||
orderBy: { id: 'asc' },
|
||||
})
|
||||
: [];
|
||||
const allRows = [...leafCategories, ...ancestorRows].filter(
|
||||
(row, idx, arr) => arr.findIndex((r) => r.id === row.id) === idx,
|
||||
);
|
||||
allRows.sort((a, b) => Number(a.id - b.id));
|
||||
return this.buildTree(allRows);
|
||||
}
|
||||
|
||||
async getCountries(): Promise<PublicCountryDto[]> {
|
||||
const rows = await this.prisma.country.findMany({
|
||||
where: { goods: { some: {} } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return rows.map(PublicCountryDto.from);
|
||||
}
|
||||
|
||||
async getTags(): Promise<PublicTagDto[]> {
|
||||
const rows = await this.prisma.tag.findMany({
|
||||
where: { goodTags: { some: {} } },
|
||||
orderBy: [
|
||||
{ tagGroup: { sortOrder: 'asc' } },
|
||||
{ sortOrder: 'asc' },
|
||||
{ id: 'asc' },
|
||||
],
|
||||
include: { tagGroup: true },
|
||||
});
|
||||
return rows.map(PublicTagDto.from);
|
||||
}
|
||||
|
||||
async getTagGroups(): Promise<PublicTagGroupDto[]> {
|
||||
const rows = await this.prisma.tagGroup.findMany({
|
||||
where: { tags: { some: { goodTags: { some: {} } } } },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return rows.map(PublicTagGroupDto.from);
|
||||
}
|
||||
|
||||
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
||||
const where: Prisma.GoodWhereInput = {};
|
||||
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
|
||||
if (query.tagIds) {
|
||||
const ids = query.tagIds
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => BigInt(s));
|
||||
if (ids.length > 0) {
|
||||
// AND logic: 商品必须同时具备所有选中的 tag
|
||||
where.AND = ids.map((id) => ({ goodTags: { some: { tagId: id } } }));
|
||||
}
|
||||
}
|
||||
if (query.keyword) {
|
||||
where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
||||
}
|
||||
if (query.categoryId !== undefined) {
|
||||
const ids = await this.collectCategoryDescendants(BigInt(query.categoryId));
|
||||
where.categoryId = { in: ids };
|
||||
}
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.good.count({ where }),
|
||||
this.prisma.good.findMany({
|
||||
where,
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
// Server-side primary sort; PublicGoodDto retains original indexes
|
||||
// for stable pagination but the final ORDER BY is mirrored below.
|
||||
orderBy: [
|
||||
{ goodPriority: 'desc' },
|
||||
{ position: { indexVal: 'asc' } },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((g) => this.toPublicGood(g)),
|
||||
total,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getGood(id: bigint): Promise<PublicGoodDto> {
|
||||
const good = await this.prisma.good.findUnique({
|
||||
where: { id },
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
});
|
||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||
return this.toPublicGood(good);
|
||||
}
|
||||
|
||||
private toPublicGood(good: {
|
||||
id: bigint;
|
||||
goodName: string;
|
||||
goodImage: string | null;
|
||||
goodPriority: number;
|
||||
country: { id: bigint; countryName: string; countryIcon: string | null };
|
||||
category: { id: bigint; categoryName: string; categoryIcon: string | null };
|
||||
tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } | null;
|
||||
position: { id: bigint; indexVal: number } | null;
|
||||
originGood: {
|
||||
goodImage: string | null;
|
||||
goodPrice: { toString(): string } | null;
|
||||
} | null;
|
||||
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } }[];
|
||||
createdAt: Date;
|
||||
}): PublicGoodDto {
|
||||
const formatGroup = (g: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
||||
g
|
||||
? {
|
||||
id: g.id.toString(),
|
||||
groupName: g.groupName,
|
||||
sortOrder: g.sortOrder,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
id: good.id.toString(),
|
||||
goodName: good.goodName,
|
||||
goodPriority: good.goodPriority,
|
||||
country: {
|
||||
id: good.country.id.toString(),
|
||||
countryName: good.country.countryName,
|
||||
countryIcon: good.country.countryIcon,
|
||||
},
|
||||
category: {
|
||||
id: good.category.id.toString(),
|
||||
categoryName: good.category.categoryName,
|
||||
categoryIcon: good.category.categoryIcon,
|
||||
},
|
||||
tag: good.tag
|
||||
? {
|
||||
id: good.tag.id.toString(),
|
||||
tagName: good.tag.tagName,
|
||||
tagColor: good.tag.tagColor,
|
||||
tagFontColor: good.tag.tagFontColor,
|
||||
group: formatGroup(good.tag.tagGroup),
|
||||
}
|
||||
: null,
|
||||
tags: good.goodTags.map((gt) => ({
|
||||
id: gt.tag.id.toString(),
|
||||
tagName: gt.tag.tagName,
|
||||
tagColor: gt.tag.tagColor,
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
group: formatGroup(gt.tag.tagGroup),
|
||||
})),
|
||||
position: good.position
|
||||
? {
|
||||
id: good.position.id.toString(),
|
||||
indexVal: good.position.indexVal,
|
||||
}
|
||||
: null,
|
||||
image: good.goodImage ?? good.originGood?.goodImage ?? null,
|
||||
price:
|
||||
good.originGood?.goodPrice === null ||
|
||||
good.originGood?.goodPrice === undefined
|
||||
? null
|
||||
: good.originGood.goodPrice.toString(),
|
||||
createdAt: good.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
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 buildTree(
|
||||
rows: PrismaCategory[],
|
||||
): PublicCategoryNodeDto[] {
|
||||
const byId = new Map<bigint, PublicCategoryNodeDto>();
|
||||
for (const row of rows) {
|
||||
byId.set(row.id, PublicCategoryNodeDto.from(row, []));
|
||||
}
|
||||
const roots: PublicCategoryNodeDto[] = [];
|
||||
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 roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import type { SyncLog } from '@prisma/client';
|
||||
|
||||
export class SyncLogDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
type!: 'CATEGORIES' | 'PRODUCTS';
|
||||
|
||||
@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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the SDS category tree of type 3 (products category).
|
||||
* Body matches the legacy inkpod client.
|
||||
*/
|
||||
async fetchCategoryTree(): Promise<SdsCategoryTreeNode[]> {
|
||||
const url = `${this.baseUrl}/category/tree/3`;
|
||||
const body = {
|
||||
withActivityArea: true,
|
||||
withPrivate: true,
|
||||
onlyHaveProduct: true,
|
||||
};
|
||||
const { data } = await firstValueFrom(
|
||||
this.http.post<SdsCategoryTreeNode[]>(url, body, { headers: POD_HEADERS }),
|
||||
);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one page of products for a given SDS category.
|
||||
*/
|
||||
async fetchProductsPage(
|
||||
categoryId: string | number,
|
||||
page = 1,
|
||||
size = 50,
|
||||
): Promise<SdsProductsPage> {
|
||||
const url = `${this.baseUrl}/products/page`;
|
||||
const { data } = await firstValueFrom(
|
||||
this.http.get<SdsProductsPage>(url, {
|
||||
headers: POD_HEADERS,
|
||||
params: { categoryId, page, size },
|
||||
}),
|
||||
);
|
||||
return data ?? {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
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' })
|
||||
syncCategories() {
|
||||
return this.service.syncCategories();
|
||||
}
|
||||
|
||||
@Post('products')
|
||||
@ApiOperation({ summary: 'Manually trigger product sync' })
|
||||
syncProducts() {
|
||||
return this.service.syncProducts();
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 {}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SyncService } 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> = {
|
||||
fetchCategoryTree: jest.fn(),
|
||||
fetchProductsPage: jest.fn(),
|
||||
};
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
||||
providers: [
|
||||
SyncService,
|
||||
{ provide: SdsClientService, useValue: sdsMock },
|
||||
PrismaService,
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SyncService);
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SdsClientService, SdsCategoryTreeNode, SdsProduct } from './sds-client.service';
|
||||
|
||||
export interface CategorySyncResult {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ProductSyncResult {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
total: number;
|
||||
leafCategories: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
private running = { categories: false, products: false };
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sds: SdsClientService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Hourly full sync — runs `syncCategories` first (since product
|
||||
* sync depends on knowing which leaf categories exist) and then
|
||||
* `syncProducts`.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
async hourlyCron(): Promise<void> {
|
||||
try {
|
||||
await this.syncCategories();
|
||||
await this.syncProducts();
|
||||
} catch (err) {
|
||||
this.logger.error('Hourly cron sync failed', err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async syncCategories(): Promise<CategorySyncResult> {
|
||||
if (this.running.categories) {
|
||||
throw new Error('Category sync already in progress');
|
||||
}
|
||||
this.running.categories = true;
|
||||
const log = await this.prisma.syncLog.create({
|
||||
data: { type: 'CATEGORIES', status: 'RUNNING' },
|
||||
});
|
||||
try {
|
||||
const tree = await this.sds.fetchCategoryTree();
|
||||
const flat = this.flattenCategoryTree(tree);
|
||||
this.logger.log(`Fetched ${flat.length} SDS categories`);
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
for (const node of flat) {
|
||||
const existing = await this.prisma.category.findUnique({
|
||||
where: { sdsCategoryId: node.sdsId },
|
||||
});
|
||||
if (!existing) {
|
||||
await this.prisma.category.create({
|
||||
data: {
|
||||
sdsCategoryId: node.sdsId,
|
||||
categoryName: node.name,
|
||||
categoryIcon: node.icon ?? null,
|
||||
},
|
||||
});
|
||||
inserted++;
|
||||
} else {
|
||||
await this.prisma.category.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
categoryName: node.name,
|
||||
categoryIcon: node.icon ?? null,
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: wire up parents by sdsCategoryId.
|
||||
for (const node of flat) {
|
||||
if (!node.parentSdsId) continue;
|
||||
const child = await this.prisma.category.findUnique({
|
||||
where: { sdsCategoryId: node.sdsId },
|
||||
});
|
||||
const parent = await this.prisma.category.findUnique({
|
||||
where: { sdsCategoryId: node.parentSdsId },
|
||||
});
|
||||
if (child && parent && child.parentCategoryId !== parent.id) {
|
||||
await this.prisma.category.update({
|
||||
where: { id: child.id },
|
||||
data: { parentCategoryId: parent.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
status: 'SUCCESS',
|
||||
finishedAt: new Date(),
|
||||
message: `inserted=${inserted} updated=${updated} total=${flat.length}`,
|
||||
},
|
||||
});
|
||||
return { inserted, updated, total: flat.length };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
finishedAt: new Date(),
|
||||
message,
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
this.running.categories = false;
|
||||
}
|
||||
}
|
||||
|
||||
async syncProducts(): Promise<ProductSyncResult> {
|
||||
if (this.running.products) {
|
||||
throw new Error('Product sync already in progress');
|
||||
}
|
||||
this.running.products = true;
|
||||
const log = await this.prisma.syncLog.create({
|
||||
data: { type: 'PRODUCTS', status: 'RUNNING' },
|
||||
});
|
||||
try {
|
||||
// Identify leaf categories — those with no children.
|
||||
const all = await this.prisma.category.findMany({
|
||||
select: { id: true, sdsCategoryId: true },
|
||||
});
|
||||
const parents = await this.prisma.category.findMany({
|
||||
where: { parent: { isNot: null } },
|
||||
select: { parentCategoryId: true },
|
||||
});
|
||||
const parentIds = new Set(parents.map((p) => p.parentCategoryId!));
|
||||
const leafRows = all.filter((c) => !parentIds.has(c.id) && c.sdsCategoryId);
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let total = 0;
|
||||
|
||||
for (const leaf of leafRows) {
|
||||
const sdsCategoryId = leaf.sdsCategoryId!;
|
||||
let page = 1;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const resp = await this.sds.fetchProductsPage(sdsCategoryId, page, 50);
|
||||
const products = resp.items ?? resp.content ?? [];
|
||||
if (products.length === 0) break;
|
||||
for (const product of products) {
|
||||
const upserted = await this.upsertOriginGood(product, sdsCategoryId);
|
||||
if (upserted === 'inserted') inserted++;
|
||||
else updated++;
|
||||
total++;
|
||||
}
|
||||
if (products.length < 50) break;
|
||||
page++;
|
||||
if (page > 200) {
|
||||
// Safety net — at most 10k products per category.
|
||||
this.logger.warn(`Reached 200-page safety cap for ${sdsCategoryId}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
status: 'SUCCESS',
|
||||
finishedAt: new Date(),
|
||||
message: `inserted=${inserted} updated=${updated} total=${total} leafCategories=${leafRows.length}`,
|
||||
},
|
||||
});
|
||||
return { inserted, updated, total, leafCategories: leafRows.length };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.prisma.syncLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
finishedAt: new Date(),
|
||||
message,
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
this.running.products = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus(limit = 20) {
|
||||
return this.prisma.syncLog.findMany({
|
||||
orderBy: { startedAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`.
|
||||
*/
|
||||
flattenCategoryTree(
|
||||
nodes: SdsCategoryTreeNode[],
|
||||
parentSdsId?: string,
|
||||
): Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> {
|
||||
const out: Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> = [];
|
||||
const walk = (node: SdsCategoryTreeNode, parent?: string) => {
|
||||
const sdsId = String(node.id);
|
||||
if (sdsId === '' || sdsId === 'undefined' || sdsId === 'null') return;
|
||||
out.push({
|
||||
sdsId,
|
||||
parentSdsId: parent,
|
||||
name: String(node.name ?? node.title ?? sdsId),
|
||||
icon: node.icon ? String(node.icon) : undefined,
|
||||
});
|
||||
if (Array.isArray(node.children)) {
|
||||
for (const child of node.children) walk(child, sdsId);
|
||||
}
|
||||
};
|
||||
for (const root of nodes) walk(root, parentSdsId);
|
||||
return out;
|
||||
}
|
||||
|
||||
private async upsertOriginGood(
|
||||
product: SdsProduct,
|
||||
sdsCategoryId: string,
|
||||
): Promise<'inserted' | 'updated'> {
|
||||
const sdsGoodId = String(product.id);
|
||||
const existing = await this.prisma.originGood.findUnique({
|
||||
where: { sdsGoodId },
|
||||
});
|
||||
const goodName = String(product.name ?? product.title ?? sdsGoodId);
|
||||
const goodImage = product.psd_img_url
|
||||
? String(product.psd_img_url)
|
||||
: product.blankDesignUrl
|
||||
? String(product.blankDesignUrl)
|
||||
: product.thumbImgUrl
|
||||
? String(product.thumbImgUrl)
|
||||
: product.show_img
|
||||
? String(product.show_img)
|
||||
: product.img_url
|
||||
? String(product.img_url)
|
||||
: product.pic
|
||||
? String(product.pic)
|
||||
: product.image
|
||||
? String(product.image)
|
||||
: null;
|
||||
const priceValue = product.currentPrice ?? product.price;
|
||||
let goodPrice: Prisma.Decimal | null = null;
|
||||
if (priceValue !== undefined && priceValue !== null) {
|
||||
const n = typeof priceValue === 'string' ? Number(priceValue) : priceValue;
|
||||
if (Number.isFinite(n)) {
|
||||
goodPrice = new Prisma.Decimal(n);
|
||||
}
|
||||
}
|
||||
const data: Prisma.OriginGoodUncheckedUpdateInput = {
|
||||
sdsCategoryId,
|
||||
goodName,
|
||||
goodImage,
|
||||
goodPrice,
|
||||
};
|
||||
|
||||
if (!existing) {
|
||||
await this.prisma.originGood.create({
|
||||
data: {
|
||||
sdsGoodId,
|
||||
sdsCategoryId,
|
||||
goodName,
|
||||
goodImage,
|
||||
goodPrice,
|
||||
},
|
||||
});
|
||||
return 'inserted';
|
||||
}
|
||||
await this.prisma.originGood.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
});
|
||||
return 'updated';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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[];
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 {}
|
||||
@@ -0,0 +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 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +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 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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[];
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 {}
|
||||
@@ -0,0 +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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +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),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user