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
Hide 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ê
...
@@ -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`
và
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
...
...
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
...
@@ -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"
)
}
}
...
...
prisma/seed.ts
View file @
70acfd22
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'
);
}
}
...
...
src/common/errors/error-code.ts
View file @
70acfd22
...
@@ -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'
,
...
...
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';
...
@@ -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
;
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