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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user