Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
finwise-miniapp-be
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
finwise-miniapp-be
Commits
70acfd22
Commit
70acfd22
authored
Jul 28, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(categories): add category management
parent
cfbe1566
Changes
13
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
1331 additions
and
19 deletions
+1331
-19
memory.md
.agents/memory.md
+8
-5
migration.sql
...ions/20260728190000_add_category_management/migration.sql
+39
-0
schema.prisma
prisma/schema.prisma
+15
-12
seed.ts
prisma/seed.ts
+130
-1
error-code.ts
src/common/errors/error-code.ts
+5
-0
swagger.config.ts
src/config/swagger.config.ts
+371
-1
category.controller.ts
src/modules/categories/category.controller.ts
+100
-0
category.dto.ts
src/modules/categories/category.dto.ts
+58
-0
category.repository.ts
src/modules/categories/category.repository.ts
+271
-0
category.route.ts
src/modules/categories/category.route.ts
+40
-0
category.service.ts
src/modules/categories/category.service.ts
+232
-0
category.validation.ts
src/modules/categories/category.validation.ts
+60
-0
index.ts
src/routes/index.ts
+2
-0
No files found.
.agents/memory.md
View file @
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ê
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`
và
`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
...
...
prisma/migrations/20260728190000_add_category_management/migration.sql
0 → 100644
View file @
70acfd22
-- 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"
;
prisma/schema.prisma
View file @
70acfd22
...
...
@@ -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"
)
}
...
...
prisma/seed.ts
View file @
70acfd22
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'
);
}
...
...
src/common/errors/error-code.ts
View file @
70acfd22
...
...
@@ -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'
,
...
...
src/config/swagger.config.ts
View file @
70acfd22
This diff is collapsed.
Click to expand it.
src/modules/categories/category.controller.ts
0 → 100644
View file @
70acfd22
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
);
}
};
}
src/modules/categories/category.dto.ts
0 → 100644
View file @
70acfd22
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
[];
}
src/modules/categories/category.repository.ts
0 → 100644
View file @
70acfd22
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
,
});
}
}
src/modules/categories/category.route.ts
0 → 100644
View file @
70acfd22
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
;
src/modules/categories/category.service.ts
0 → 100644
View file @
70acfd22
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
,
);
}
}
}
src/modules/categories/category.validation.ts
0 → 100644
View file @
70acfd22
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'
,
});
src/routes/index.ts
View file @
70acfd22
...
...
@@ -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
;
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment