Commit 70acfd22 authored by ThinhNC's avatar ThinhNC

feat(categories): add category management

parent cfbe1566
......@@ -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.
- API version hiện tại là `/api/v1`.
- 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
......@@ -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`.
- 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.
- Schema có model tài chính chưa được expose qua route: Category, Transaction và
Budget. Wallet đã có API theo ownership, dùng archive thay cho xóa vật lý.
- Migration `20260728170000_improve_wallet_management` chỉ đồng bộ thay đổi của
Wallet. Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của
auth, Category và Budget đã được commit trước đó.
- Transaction và Budget chưa được expose qua route. Wallet và Category đã có API theo
ownership; Category đồng thời trả các category hệ thống dùng chung.
- Các migration `20260728170000_improve_wallet_management`
`20260728190000_add_category_management` đồng bộ thay đổi của Wallet và Category.
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
......
-- 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";
......@@ -76,23 +76,26 @@ model Wallet {
model Category {
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
name String
type TransactionType
icon String?
color String?
isSystem Boolean @default(false) @map("is_system")
isArchived Boolean @default(false) @map("is_archived")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryHierarchy")
transactions Transaction[]
budgets Budget[]
@@unique([userId, name, type])
@@index([userId])
@@index([userId, isArchived])
@@index([type, isArchived])
@@index([parentId])
@@map("categories")
}
......
import { PrismaClient } from '@prisma/client';
import { PrismaClient, TransactionType } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
......@@ -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');
}
......
......@@ -11,6 +11,11 @@ export const ERROR_CODE = {
DUPLICATE_ENTRY: 'DUPLICATE_ENTRY',
WALLET_ARCHIVED: 'WALLET_ARCHIVED',
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_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED',
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';
import authRoute from '../modules/auth/auth.route';
import userRoute from '../modules/users/user.route';
import walletRoute from '../modules/wallets/wallet.route';
import categoryRoute from '../modules/categories/category.route';
const router = Router();
......@@ -12,5 +13,6 @@ router.get('/health', (req, res) => {
router.use('/auth', authRoute);
router.use('/users', userRoute);
router.use('/wallets', walletRoute);
router.use('/categories', categoryRoute);
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