Commit befb10a2 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/category-management' into 'develop'

feat(categories): add category management

See merge request !11
parents 395534d7 70acfd22
...@@ -13,6 +13,8 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê ...@@ -13,6 +13,8 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
refresh token. refresh token.
- API version hiện tại là `/api/v1`. - API version hiện tại là `/api/v1`.
- Package manager chuẩn là pnpm. - Package manager chuẩn là pnpm.
- Category hệ thống là dữ liệu dùng chung (`userId = null`, `isSystem = true`) và chỉ đọc
qua API; category người dùng được kiểm soát ownership, dùng archive thay cho xóa vật lý.
## Trạng thái đã biết ## Trạng thái đã biết
...@@ -23,11 +25,12 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê ...@@ -23,11 +25,12 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
`env.config.ts` mặc định `8888`, `.env.example` dùng `7777`. `env.config.ts` mặc định `8888`, `.env.example` dùng `7777`.
- README và một số chuỗi tiếng Việt đang có dấu hiệu sai encoding. Không mở rộng - README và một số chuỗi tiếng Việt đang có dấu hiệu sai encoding. Không mở rộng
phạm vi để sửa hàng loạt nếu công việc hiện tại không yêu cầu. phạm vi để sửa hàng loạt nếu công việc hiện tại không yêu cầu.
- Schema có model tài chính chưa được expose qua route: Category, Transaction và - Transaction và Budget chưa được expose qua route. Wallet và Category đã có API theo
Budget. Wallet đã có API theo ownership, dùng archive thay cho xóa vật lý. ownership; Category đồng thời trả các category hệ thống dùng chung.
- Migration `20260728170000_improve_wallet_management` chỉ đồng bộ thay đổi của - Các migration `20260728170000_improve_wallet_management`
Wallet. Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của `20260728190000_add_category_management` đồng bộ thay đổi của Wallet và Category.
auth, Category và Budget đã được commit trước đó. Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth và
Budget đã được commit trước đó.
## Khi cập nhật file này ## Khi cập nhật file này
......
-- Category records with a NULL user_id are shared system defaults.
ALTER TABLE "categories"
ALTER COLUMN "user_id" DROP NOT NULL,
ADD COLUMN "parent_id" UUID,
ADD COLUMN "icon" TEXT,
ADD COLUMN "color" TEXT,
ADD COLUMN "is_system" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "is_archived" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "categories"
ADD CONSTRAINT "categories_parent_id_fkey"
FOREIGN KEY ("parent_id") REFERENCES "categories"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "categories"
ADD CONSTRAINT "categories_system_owner_check"
CHECK (
("is_system" = true AND "user_id" IS NULL)
OR ("is_system" = false AND "user_id" IS NOT NULL)
);
CREATE UNIQUE INDEX "categories_system_name_type_ci_key"
ON "categories"(LOWER("name"), "type")
WHERE "is_system" = true;
CREATE UNIQUE INDEX "categories_user_name_type_ci_key"
ON "categories"("user_id", LOWER("name"), "type")
WHERE "is_system" = false;
CREATE INDEX "categories_user_id_is_archived_idx"
ON "categories"("user_id", "is_archived");
CREATE INDEX "categories_type_is_archived_idx"
ON "categories"("type", "is_archived");
CREATE INDEX "categories_parent_id_idx"
ON "categories"("parent_id");
DROP INDEX IF EXISTS "categories_user_id_idx";
...@@ -75,24 +75,27 @@ model Wallet { ...@@ -75,24 +75,27 @@ model Wallet {
} }
model Category { model Category {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid userId String? @map("user_id") @db.Uuid
parentId String? @map("parent_id") @db.Uuid parentId String? @map("parent_id") @db.Uuid
name String name String
type TransactionType type TransactionType
icon String? icon String?
color String? color String?
createdAt DateTime @default(now()) @map("created_at") isSystem Boolean @default(false) @map("is_system")
updatedAt DateTime @updatedAt @map("updated_at") isArchived Boolean @default(false) @map("is_archived")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) updatedAt DateTime @updatedAt @map("updated_at")
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: SetNull) parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryHierarchy") children Category[] @relation("CategoryHierarchy")
transactions Transaction[] transactions Transaction[]
budgets Budget[] budgets Budget[]
@@unique([userId, name, type]) @@unique([userId, name, type])
@@index([userId]) @@index([userId, isArchived])
@@index([type, isArchived])
@@index([parentId]) @@index([parentId])
@@map("categories") @@map("categories")
} }
......
import { PrismaClient } from '@prisma/client'; import { PrismaClient, TransactionType } from '@prisma/client';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
...@@ -77,6 +77,135 @@ async function main() { ...@@ -77,6 +77,135 @@ async function main() {
}, },
}); });
const systemCategories: Array<{
id: string;
parentId?: string;
name: string;
type: TransactionType;
icon: string;
color: string;
}> = [
{
id: '10000000-0000-4000-8000-000000000001',
name: 'Salary',
type: TransactionType.INCOME,
icon: 'briefcase',
color: '#16A34A',
},
{
id: '10000000-0000-4000-8000-000000000002',
name: 'Investment',
type: TransactionType.INCOME,
icon: 'trending-up',
color: '#0D9488',
},
{
id: '10000000-0000-4000-8000-000000000003',
name: 'Other Income',
type: TransactionType.INCOME,
icon: 'circle-plus',
color: '#22C55E',
},
{
id: '20000000-0000-4000-8000-000000000001',
name: 'Food & Dining',
type: TransactionType.EXPENSE,
icon: 'utensils',
color: '#F97316',
},
{
id: '20000000-0000-4000-8000-000000000002',
parentId: '20000000-0000-4000-8000-000000000001',
name: 'Groceries',
type: TransactionType.EXPENSE,
icon: 'shopping-basket',
color: '#FB923C',
},
{
id: '20000000-0000-4000-8000-000000000003',
parentId: '20000000-0000-4000-8000-000000000001',
name: 'Restaurants',
type: TransactionType.EXPENSE,
icon: 'chef-hat',
color: '#EA580C',
},
{
id: '20000000-0000-4000-8000-000000000004',
name: 'Transport',
type: TransactionType.EXPENSE,
icon: 'car',
color: '#3B82F6',
},
{
id: '20000000-0000-4000-8000-000000000005',
name: 'Shopping',
type: TransactionType.EXPENSE,
icon: 'shopping-bag',
color: '#A855F7',
},
{
id: '20000000-0000-4000-8000-000000000006',
name: 'Bills & Utilities',
type: TransactionType.EXPENSE,
icon: 'receipt',
color: '#EAB308',
},
{
id: '20000000-0000-4000-8000-000000000007',
name: 'Health',
type: TransactionType.EXPENSE,
icon: 'heart-pulse',
color: '#EF4444',
},
{
id: '20000000-0000-4000-8000-000000000008',
name: 'Entertainment',
type: TransactionType.EXPENSE,
icon: 'gamepad-2',
color: '#EC4899',
},
{
id: '20000000-0000-4000-8000-000000000009',
name: 'Education',
type: TransactionType.EXPENSE,
icon: 'graduation-cap',
color: '#6366F1',
},
{
id: '20000000-0000-4000-8000-000000000010',
name: 'Other Expense',
type: TransactionType.EXPENSE,
icon: 'ellipsis',
color: '#64748B',
},
];
for (const category of systemCategories) {
await prisma.category.upsert({
where: { id: category.id },
update: {
userId: null,
parentId: category.parentId ?? null,
name: category.name,
type: category.type,
icon: category.icon,
color: category.color,
isSystem: true,
isArchived: false,
},
create: {
id: category.id,
userId: null,
parentId: category.parentId,
name: category.name,
type: category.type,
icon: category.icon,
color: category.color,
isSystem: true,
},
});
}
console.log('Seed completed successfully'); console.log('Seed completed successfully');
} }
......
...@@ -11,6 +11,11 @@ export const ERROR_CODE = { ...@@ -11,6 +11,11 @@ export const ERROR_CODE = {
DUPLICATE_ENTRY: 'DUPLICATE_ENTRY', DUPLICATE_ENTRY: 'DUPLICATE_ENTRY',
WALLET_ARCHIVED: 'WALLET_ARCHIVED', WALLET_ARCHIVED: 'WALLET_ARCHIVED',
DEFAULT_WALLET_REQUIRED: 'DEFAULT_WALLET_REQUIRED', DEFAULT_WALLET_REQUIRED: 'DEFAULT_WALLET_REQUIRED',
CATEGORY_SYSTEM_PROTECTED: 'CATEGORY_SYSTEM_PROTECTED',
CATEGORY_ARCHIVED: 'CATEGORY_ARCHIVED',
CATEGORY_PARENT_INVALID: 'CATEGORY_PARENT_INVALID',
CATEGORY_CYCLE: 'CATEGORY_CYCLE',
CATEGORY_IN_USE: 'CATEGORY_IN_USE',
CRAWL_JOB_NOT_FOUND: 'CRAWL_JOB_NOT_FOUND', CRAWL_JOB_NOT_FOUND: 'CRAWL_JOB_NOT_FOUND',
CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED', CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED',
PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED', PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED',
......
This diff is collapsed.
import { NextFunction, Request, Response } from 'express';
import {
CategoryQueryDto,
CategoryTreeQueryDto,
CreateCategoryDto,
UpdateCategoryDto,
} from './category.dto';
import { CategoryService } from './category.service';
export class CategoryController {
private readonly service = new CategoryService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findAll(
req.user.id,
req.query as unknown as CategoryQueryDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findTree = async (req: Request, res: Response, next: NextFunction) => {
try {
const categories = await this.service.findTree(
req.user.id,
req.query as unknown as CategoryTreeQueryDto,
);
res.json({ success: true, data: categories });
} catch (error) {
next(error);
}
};
findById = async (req: Request, res: Response, next: NextFunction) => {
try {
const category = await this.service.findById(req.user.id, req.params.id);
res.json({ success: true, data: category });
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const category = await this.service.create(
req.user.id,
req.body as CreateCategoryDto,
);
res.status(201).json({ success: true, data: category });
} catch (error) {
next(error);
}
};
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const category = await this.service.update(
req.user.id,
req.params.id,
req.body as UpdateCategoryDto,
);
res.json({ success: true, data: category });
} catch (error) {
next(error);
}
};
archive = async (req: Request, res: Response, next: NextFunction) => {
try {
const category = await this.service.archive(req.user.id, req.params.id);
res.json({
success: true,
message: 'Category archived successfully',
data: category,
});
} catch (error) {
next(error);
}
};
restore = async (req: Request, res: Response, next: NextFunction) => {
try {
const category = await this.service.restore(req.user.id, req.params.id);
res.json({ success: true, data: category });
} catch (error) {
next(error);
}
};
}
import { TransactionType } from '@prisma/client';
export type CategorySource = 'ALL' | 'SYSTEM' | 'USER';
export type CategorySortField = 'name' | 'createdAt' | 'updatedAt';
export type SortOrder = 'asc' | 'desc';
export interface CategoryQueryDto {
search?: string;
type?: TransactionType;
source: CategorySource;
parentId?: string | null;
includeArchived: boolean;
sortBy: CategorySortField;
order: SortOrder;
page: number;
limit: number;
}
export interface CategoryTreeQueryDto {
search?: string;
type?: TransactionType;
source: CategorySource;
includeArchived: boolean;
}
export interface CreateCategoryDto {
name: string;
type: TransactionType;
parentId?: string | null;
icon?: string | null;
color?: string | null;
}
export interface UpdateCategoryDto {
name?: string;
type?: TransactionType;
parentId?: string | null;
icon?: string | null;
color?: string | null;
}
export interface CategoryResponseDto {
id: string;
parentId: string | null;
name: string;
type: TransactionType;
icon: string | null;
color: string | null;
isSystem: boolean;
isArchived: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface CategoryTreeNodeDto extends CategoryResponseDto {
children: CategoryTreeNodeDto[];
}
import { Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import {
CategoryQueryDto,
CategoryResponseDto,
CategoryTreeQueryDto,
CreateCategoryDto,
UpdateCategoryDto,
} from './category.dto';
const categorySelect = {
id: true,
parentId: true,
name: true,
type: true,
icon: true,
color: true,
isSystem: true,
isArchived: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.CategorySelect;
function visibleWhere(userId: string): Prisma.CategoryWhereInput {
return {
OR: [
{ userId },
{ isSystem: true, userId: null },
],
};
}
function sourceWhere(
userId: string,
source: CategoryQueryDto['source'],
): Prisma.CategoryWhereInput {
if (source === 'SYSTEM') {
return { isSystem: true, userId: null };
}
if (source === 'USER') {
return { isSystem: false, userId };
}
return visibleWhere(userId);
}
async function runSerializableTransaction<T>(
operation: (transaction: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await prisma.$transaction(operation, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});
} catch (error) {
const shouldRetry = error instanceof Prisma.PrismaClientKnownRequestError
&& error.code === 'P2034'
&& attempt < maxAttempts;
if (!shouldRetry) {
throw error;
}
}
}
throw new Error('Serializable transaction retry limit reached');
}
export class CategoryRepository {
async findAll(userId: string, query: CategoryQueryDto) {
const {
search,
type,
source,
parentId,
includeArchived,
sortBy,
order,
page,
limit,
} = query;
const where: Prisma.CategoryWhereInput = {
AND: [
sourceWhere(userId, source),
...(search
? [{ name: { contains: search, mode: Prisma.QueryMode.insensitive } }]
: []),
...(type ? [{ type }] : []),
...(parentId !== undefined ? [{ parentId }] : []),
...(includeArchived ? [] : [{ isArchived: false }]),
],
};
const skip = (page - 1) * limit;
const [categories, total] = await prisma.$transaction([
prisma.category.findMany({
where,
select: categorySelect,
orderBy: { [sortBy]: order },
skip,
take: limit,
}),
prisma.category.count({ where }),
]);
return {
data: categories,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
findAllForTree(userId: string, query: CategoryTreeQueryDto) {
return prisma.category.findMany({
where: {
AND: [
sourceWhere(userId, query.source),
...(query.type ? [{ type: query.type }] : []),
...(query.includeArchived ? [] : [{ isArchived: false }]),
],
},
select: categorySelect,
orderBy: [
{ name: 'asc' },
{ createdAt: 'asc' },
],
});
}
findById(userId: string, id: string) {
return prisma.category.findFirst({
where: {
id,
AND: [visibleWhere(userId)],
},
select: categorySelect,
});
}
findByName(
userId: string,
name: string,
type: CategoryResponseDto['type'],
excludeId?: string,
) {
return prisma.category.findFirst({
where: {
name: { equals: name, mode: Prisma.QueryMode.insensitive },
type,
AND: [visibleWhere(userId)],
...(excludeId ? { id: { not: excludeId } } : {}),
},
select: { id: true },
});
}
create(userId: string, data: CreateCategoryDto) {
return prisma.category.create({
data: {
userId,
name: data.name,
type: data.type,
parentId: data.parentId,
icon: data.icon,
color: data.color,
},
select: categorySelect,
});
}
update(id: string, data: UpdateCategoryDto) {
return prisma.category.update({
where: { id },
data,
select: categorySelect,
});
}
async hasDependencies(id: string) {
const [children, transactions, budgets] = await prisma.$transaction([
prisma.category.count({ where: { parentId: id } }),
prisma.transaction.count({ where: { categoryId: id } }),
prisma.budget.count({ where: { categoryId: id } }),
]);
return children > 0 || transactions > 0 || budgets > 0;
}
async wouldCreateCycle(categoryId: string, parentId: string) {
const visited = new Set<string>();
let currentId: string | null = parentId;
while (currentId !== null) {
if (currentId === categoryId) {
return true;
}
if (visited.has(currentId)) {
return true;
}
visited.add(currentId);
const category: { parentId: string | null } | null = await prisma.category.findUnique({
where: { id: currentId },
select: { parentId: true },
});
currentId = category?.parentId ?? null;
}
return false;
}
async archiveBranch(userId: string, id: string) {
return runSerializableTransaction(async (transaction) => {
const target = await transaction.category.findFirst({
where: { id, userId, isSystem: false },
select: categorySelect,
});
if (!target || target.isArchived) {
return target;
}
const categories = await transaction.category.findMany({
where: { userId, isSystem: false },
select: { id: true, parentId: true },
});
const ids = new Set([id]);
let added = true;
while (added) {
added = false;
for (const category of categories) {
if (
category.parentId !== null
&& ids.has(category.parentId)
&& !ids.has(category.id)
) {
ids.add(category.id);
added = true;
}
}
}
await transaction.category.updateMany({
where: { id: { in: [...ids] }, userId },
data: { isArchived: true },
});
return transaction.category.findUniqueOrThrow({
where: { id },
select: categorySelect,
});
});
}
restore(id: string) {
return prisma.category.update({
where: { id },
data: { isArchived: false },
select: categorySelect,
});
}
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { CategoryController } from './category.controller';
import {
categoryParamsSchema,
categoryTreeSchema,
createCategorySchema,
findCategoriesSchema,
updateCategorySchema,
} from './category.validation';
const router = Router();
const controller = new CategoryController();
router.use(authMiddleware);
router.get('/', validate(findCategoriesSchema, 'query'), controller.findAll);
router.get('/tree', validate(categoryTreeSchema, 'query'), controller.findTree);
router.post('/', validate(createCategorySchema), controller.create);
router.get('/:id', validate(categoryParamsSchema, 'params'), controller.findById);
router.put(
'/:id',
validate(categoryParamsSchema, 'params'),
validate(updateCategorySchema),
controller.update,
);
router.patch(
'/:id/restore',
validate(categoryParamsSchema, 'params'),
controller.restore,
);
router.delete(
'/:id',
validate(categoryParamsSchema, 'params'),
controller.archive,
);
export default router;
import { Prisma } from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import {
CategoryResponseDto,
CategoryTreeNodeDto,
CategoryTreeQueryDto,
CreateCategoryDto,
UpdateCategoryDto,
CategoryQueryDto,
} from './category.dto';
import { CategoryRepository } from './category.repository';
export class CategoryService {
private readonly repository = new CategoryRepository();
findAll(userId: string, query: CategoryQueryDto) {
return this.repository.findAll(userId, query);
}
async findTree(userId: string, query: CategoryTreeQueryDto) {
const categories = await this.repository.findAllForTree(userId, query);
const nodes = new Map<string, CategoryTreeNodeDto>();
for (const category of categories) {
nodes.set(category.id, { ...category, children: [] });
}
const roots: CategoryTreeNodeDto[] = [];
for (const category of categories) {
const node = nodes.get(category.id);
if (!node) {
continue;
}
const parent = category.parentId ? nodes.get(category.parentId) : undefined;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
if (!query.search) {
return roots;
}
const search = query.search.toLocaleLowerCase();
const prune = (node: CategoryTreeNodeDto): CategoryTreeNodeDto | null => {
const children = node.children
.map(prune)
.filter((child): child is CategoryTreeNodeDto => child !== null);
if (node.name.toLocaleLowerCase().includes(search) || children.length > 0) {
return { ...node, children };
}
return null;
};
return roots
.map(prune)
.filter((node): node is CategoryTreeNodeDto => node !== null);
}
async findById(userId: string, id: string) {
const category = await this.repository.findById(userId, id);
if (!category) {
throw new AppError('Category not found', 404, ERROR_CODE.NOT_FOUND);
}
return category;
}
async create(userId: string, data: CreateCategoryDto) {
await this.ensureUniqueName(userId, data.name, data.type);
if (data.parentId) {
await this.ensureValidParent(userId, data.parentId, data.type);
}
try {
return await this.repository.create(userId, data);
} catch (error) {
this.handleUniqueConstraint(error);
throw error;
}
}
async update(userId: string, id: string, data: UpdateCategoryDto) {
const category = await this.findById(userId, id);
this.ensureMutable(category);
const nextType = data.type ?? category.type;
const nextName = data.name ?? category.name;
const nextParentId = data.parentId !== undefined ? data.parentId : category.parentId;
if (data.name !== undefined || data.type !== undefined) {
await this.ensureUniqueName(userId, nextName, nextType, id);
}
if (data.type !== undefined && data.type !== category.type) {
const hasDependencies = await this.repository.hasDependencies(id);
if (hasDependencies) {
throw new AppError(
'Category type cannot be changed while it has children, transactions, or budgets',
409,
ERROR_CODE.CATEGORY_IN_USE,
);
}
}
if (nextParentId) {
await this.ensureValidParent(userId, nextParentId, nextType);
if (await this.repository.wouldCreateCycle(id, nextParentId)) {
throw new AppError(
'Category hierarchy cannot contain a cycle',
409,
ERROR_CODE.CATEGORY_CYCLE,
);
}
}
try {
return await this.repository.update(id, data);
} catch (error) {
this.handleUniqueConstraint(error);
throw error;
}
}
async archive(userId: string, id: string) {
const category = await this.findById(userId, id);
this.ensureUserOwned(category);
if (category.isArchived) {
return category;
}
const archived = await this.repository.archiveBranch(userId, id);
if (!archived) {
throw new AppError('Category not found', 404, ERROR_CODE.NOT_FOUND);
}
return archived;
}
async restore(userId: string, id: string) {
const category = await this.findById(userId, id);
this.ensureUserOwned(category);
if (!category.isArchived) {
return category;
}
if (category.parentId) {
await this.ensureValidParent(userId, category.parentId, category.type);
}
return this.repository.restore(id);
}
private async ensureValidParent(
userId: string,
parentId: string,
type: CategoryResponseDto['type'],
) {
const parent = await this.repository.findById(userId, parentId);
if (!parent || parent.isArchived || parent.type !== type) {
throw new AppError(
'Parent category must be visible, active, and have the same transaction type',
409,
ERROR_CODE.CATEGORY_PARENT_INVALID,
);
}
return parent;
}
private async ensureUniqueName(
userId: string,
name: string,
type: CategoryResponseDto['type'],
excludeId?: string,
) {
const category = await this.repository.findByName(userId, name, type, excludeId);
if (category) {
throw new AppError(
'Category name already exists for this transaction type',
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
}
private ensureMutable(category: CategoryResponseDto) {
this.ensureUserOwned(category);
if (category.isArchived) {
throw new AppError(
'Restore the category before updating it',
409,
ERROR_CODE.CATEGORY_ARCHIVED,
);
}
}
private ensureUserOwned(category: CategoryResponseDto) {
if (category.isSystem) {
throw new AppError(
'System categories are read-only',
403,
ERROR_CODE.CATEGORY_SYSTEM_PROTECTED,
);
}
}
private handleUniqueConstraint(error: unknown): void {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new AppError(
'Category name already exists for this transaction type',
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
}
}
import { z } from 'zod';
const transactionTypeSchema = z.enum(['INCOME', 'EXPENSE']);
const sourceSchema = z.enum(['ALL', 'SYSTEM', 'USER']);
const nullableIconSchema = z.string().trim().min(1).max(100).nullable();
const nullableColorSchema = z
.string()
.trim()
.regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 'Color must be a valid hex color')
.nullable();
const commonFilterSchema = {
search: z.string().trim().min(1).max(100).optional(),
type: transactionTypeSchema.optional(),
source: sourceSchema.optional().default('ALL'),
includeArchived: z
.enum(['true', 'false'])
.transform((value) => value === 'true')
.optional()
.default('false'),
};
export const categoryParamsSchema = z.object({
id: z.string().uuid('Invalid category id'),
});
export const findCategoriesSchema = z.object({
...commonFilterSchema,
parentId: z
.union([z.string().uuid('Invalid parent category id'), z.literal('root')])
.transform((value) => value === 'root' ? null : value)
.optional(),
sortBy: z.enum(['name', 'createdAt', 'updatedAt']).optional().default('createdAt'),
order: z.enum(['asc', 'desc']).optional().default('desc'),
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
});
export const categoryTreeSchema = z.object(commonFilterSchema);
export const createCategorySchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(100),
type: transactionTypeSchema,
parentId: z.string().uuid('Invalid parent category id').nullable().optional(),
icon: nullableIconSchema.optional(),
color: nullableColorSchema.optional(),
});
export const updateCategorySchema = z
.object({
name: z.string().trim().min(1, 'Name cannot be empty').max(100).optional(),
type: transactionTypeSchema.optional(),
parentId: z.string().uuid('Invalid parent category id').nullable().optional(),
icon: nullableIconSchema.optional(),
color: nullableColorSchema.optional(),
})
.refine((data) => Object.keys(data).length > 0, {
message: 'At least one field is required',
});
...@@ -2,6 +2,7 @@ import { Router } from 'express'; ...@@ -2,6 +2,7 @@ import { Router } from 'express';
import authRoute from '../modules/auth/auth.route'; import authRoute from '../modules/auth/auth.route';
import userRoute from '../modules/users/user.route'; import userRoute from '../modules/users/user.route';
import walletRoute from '../modules/wallets/wallet.route'; import walletRoute from '../modules/wallets/wallet.route';
import categoryRoute from '../modules/categories/category.route';
const router = Router(); const router = Router();
...@@ -12,5 +13,6 @@ router.get('/health', (req, res) => { ...@@ -12,5 +13,6 @@ router.get('/health', (req, res) => {
router.use('/auth', authRoute); router.use('/auth', authRoute);
router.use('/users', userRoute); router.use('/users', userRoute);
router.use('/wallets', walletRoute); router.use('/wallets', walletRoute);
router.use('/categories', categoryRoute);
export default router; export default router;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment