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
132dba7b
Commit
132dba7b
authored
Aug 04, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(notifications): add notification and reminder system
parent
1b9a470d
Changes
27
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
27 changed files
with
2729 additions
and
4 deletions
+2729
-4
memory.md
.agents/memory.md
+10
-0
project.md
.agents/project.md
+2
-0
.env.example
.env.example
+4
-0
migration.sql
...0804120000_add_notification_reminder_system/migration.sql
+126
-0
schema.prisma
prisma/schema.prisma
+146
-0
error-code.ts
src/common/errors/error-code.ts
+1
-0
mail.service.ts
src/common/services/mail.service.ts
+39
-0
env.config.ts
src/config/env.config.ts
+18
-0
mail.config.ts
src/config/mail.config.ts
+1
-0
swagger.config.ts
src/config/swagger.config.ts
+605
-0
notification-delivery.service.ts
src/modules/notifications/notification-delivery.service.ts
+77
-0
notification.controller.ts
src/modules/notifications/notification.controller.ts
+79
-0
notification.dto.ts
src/modules/notifications/notification.dto.ts
+52
-0
notification.repository.ts
src/modules/notifications/notification.repository.ts
+436
-0
notification.route.ts
src/modules/notifications/notification.route.ts
+32
-0
notification.service.ts
src/modules/notifications/notification.service.ts
+290
-0
notification.validation.ts
src/modules/notifications/notification.validation.ts
+47
-0
notification.worker.ts
src/modules/notifications/notification.worker.ts
+68
-0
reminder.controller.ts
src/modules/reminders/reminder.controller.ts
+66
-0
reminder.dto.ts
src/modules/reminders/reminder.dto.ts
+47
-0
reminder.repository.ts
src/modules/reminders/reminder.repository.ts
+193
-0
reminder.route.ts
src/modules/reminders/reminder.route.ts
+28
-0
reminder.service.ts
src/modules/reminders/reminder.service.ts
+260
-0
reminder.validation.ts
src/modules/reminders/reminder.validation.ts
+84
-0
transaction.service.ts
src/modules/transactions/transaction.service.ts
+12
-4
index.ts
src/routes/index.ts
+4
-0
server.ts
src/server.ts
+2
-0
No files found.
.agents/memory.md
View file @
132dba7b
...
...
@@ -30,6 +30,14 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
-
Financial Reports là module chỉ đọc, tổng hợp trực tiếp Wallet, Transaction, Budget và
Saving Goal. Báo cáo dùng khoảng thời gian
`[from, to)`
, hỗ trợ preset ngày/tuần/tháng/năm
hoặc custom tối đa 1830 ngày, bucket theo offset múi giờ và luôn tách số tiền theo currency.
-
Notification dùng inbox theo ownership và database-backed delivery outbox với khóa chống
trùng theo sự kiện. Kênh mặc định là
`IN_APP`
; email dùng SMTP hiện có, còn Zalo/push giữ
trạng thái delivery riêng để bổ sung provider adapter sau.
-
Reminder hỗ trợ
`ONCE`
,
`DAILY`
,
`WEEKLY`
,
`MONTHLY`
,
`YEARLY`
, có khoảng lặp và ngày kết
thúc. Worker nền trong process xử lý reminder, cảnh báo ngân sách/mục tiêu và retry delivery;
có thể tắt hoặc chỉnh chu kỳ bằng các biến
`NOTIFICATION_*`
.
-
Giao dịch chi bất thường được cảnh báo khi có ít nhất 5 giao dịch lịch sử 90 ngày trong cùng
ví và số tiền mới đạt ít nhất 3 lần trung bình; đây là heuristic có thể thay bằng AI sau.
## Trạng thái đã biết
...
...
@@ -51,6 +59,8 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
lifecycle, constraint và index phục vụ theo dõi tiến độ;
`20260803120000_add_budget_currency`
thêm currency và index thời gian theo currency
cho Budget, backfill dữ liệu hiện có bằng
`VND`
.
`20260804120000_add_notification_reminder_system`
thêm notification inbox, preferences,
delivery outbox và user reminders cùng các enum/index phục vụ worker nền.
Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth đã
được commit trước đó.
...
...
.agents/project.md
View file @
132dba7b
...
...
@@ -62,6 +62,8 @@ Express router
`src/modules/`
.
-
Financial Reports tổng hợp dữ liệu hiện có theo khoảng thời gian và currency,
không lưu snapshot báo cáo riêng trong database.
-
Notification cung cấp inbox, trạng thái đã đọc, cấu hình kênh và outbox giao nhận;
Reminder hỗ trợ lịch một lần hoặc lặp lại và được xử lý bởi worker nền.
-
Email verification, password reset, cảnh báo thiết bị và quản lý session nằm
trong module auth.
...
...
.env.example
View file @
132dba7b
...
...
@@ -30,3 +30,7 @@ ALLOWED_ORIGINS=http://localhost:7777,http://localhost:3000,http://localhost:517
RECEIPT_UPLOAD_DIR=storage/receipts
RECEIPT_MAX_FILE_SIZE_MB=5
NOTIFICATION_WORKER_ENABLED=true
NOTIFICATION_WORKER_INTERVAL_MS=60000
NOTIFICATION_FINANCIAL_SCAN_INTERVAL_MS=300000
prisma/migrations/20260804120000_add_notification_reminder_system/migration.sql
0 → 100644
View file @
132dba7b
-- CreateEnum
CREATE
TYPE
"NotificationType"
AS
ENUM
(
'BUDGET_NEAR_LIMIT'
,
'BUDGET_EXCEEDED'
,
'SAVING_GOAL_NEAR_TARGET'
,
'SAVING_GOAL_ACHIEVED'
,
'SAVING_GOAL_DUE_SOON'
,
'RECURRING_PAYMENT_DUE'
,
'UNUSUAL_TRANSACTION'
,
'USER_REMINDER'
,
'SYSTEM'
);
-- CreateEnum
CREATE
TYPE
"NotificationPriority"
AS
ENUM
(
'LOW'
,
'NORMAL'
,
'HIGH'
,
'CRITICAL'
);
-- CreateEnum
CREATE
TYPE
"NotificationChannel"
AS
ENUM
(
'IN_APP'
,
'EMAIL'
,
'ZALO'
,
'PUSH'
);
-- CreateEnum
CREATE
TYPE
"NotificationDeliveryStatus"
AS
ENUM
(
'PENDING'
,
'PROCESSING'
,
'SENT'
,
'FAILED'
,
'SKIPPED'
);
-- CreateEnum
CREATE
TYPE
"NotificationSourceType"
AS
ENUM
(
'BUDGET'
,
'SAVING_GOAL'
,
'TRANSACTION'
,
'REMINDER'
,
'SYSTEM'
);
-- CreateEnum
CREATE
TYPE
"ReminderType"
AS
ENUM
(
'GENERAL'
,
'RECURRING_PAYMENT'
);
-- CreateEnum
CREATE
TYPE
"ReminderFrequency"
AS
ENUM
(
'ONCE'
,
'DAILY'
,
'WEEKLY'
,
'MONTHLY'
,
'YEARLY'
);
-- CreateTable
CREATE
TABLE
"notifications"
(
"id"
UUID
NOT
NULL
,
"user_id"
UUID
NOT
NULL
,
"type"
"NotificationType"
NOT
NULL
,
"priority"
"NotificationPriority"
NOT
NULL
DEFAULT
'NORMAL'
,
"title"
VARCHAR
(
160
)
NOT
NULL
,
"message"
TEXT
NOT
NULL
,
"channels"
"NotificationChannel"
[]
NOT
NULL
DEFAULT
ARRAY
[
'IN_APP'
]::
"NotificationChannel"
[],
"data"
JSONB
,
"action_url"
VARCHAR
(
500
),
"source_type"
"NotificationSourceType"
,
"source_id"
UUID
,
"dedup_key"
VARCHAR
(
255
)
NOT
NULL
,
"read_at"
TIMESTAMP
(
3
),
"expires_at"
TIMESTAMP
(
3
),
"created_at"
TIMESTAMP
(
3
)
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP
,
"updated_at"
TIMESTAMP
(
3
)
NOT
NULL
,
CONSTRAINT
"notifications_pkey"
PRIMARY
KEY
(
"id"
),
CONSTRAINT
"notifications_channels_not_empty_check"
CHECK
(
cardinality
(
"channels"
)
>
0
)
);
-- CreateTable
CREATE
TABLE
"notification_deliveries"
(
"id"
UUID
NOT
NULL
,
"notification_id"
UUID
NOT
NULL
,
"channel"
"NotificationChannel"
NOT
NULL
,
"status"
"NotificationDeliveryStatus"
NOT
NULL
DEFAULT
'PENDING'
,
"attempt_count"
INTEGER
NOT
NULL
DEFAULT
0
,
"next_attempt_at"
TIMESTAMP
(
3
)
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP
,
"sent_at"
TIMESTAMP
(
3
),
"failure_reason"
VARCHAR
(
500
),
"provider_message_id"
VARCHAR
(
255
),
"created_at"
TIMESTAMP
(
3
)
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP
,
"updated_at"
TIMESTAMP
(
3
)
NOT
NULL
,
CONSTRAINT
"notification_deliveries_pkey"
PRIMARY
KEY
(
"id"
),
CONSTRAINT
"notification_deliveries_external_channel_check"
CHECK
(
"channel"
<>
'IN_APP'
)
);
-- CreateTable
CREATE
TABLE
"notification_settings"
(
"id"
UUID
NOT
NULL
,
"user_id"
UUID
NOT
NULL
,
"channels"
"NotificationChannel"
[]
NOT
NULL
DEFAULT
ARRAY
[
'IN_APP'
]::
"NotificationChannel"
[],
"budget_alerts_enabled"
BOOLEAN
NOT
NULL
DEFAULT
true
,
"saving_goal_alerts_enabled"
BOOLEAN
NOT
NULL
DEFAULT
true
,
"reminder_alerts_enabled"
BOOLEAN
NOT
NULL
DEFAULT
true
,
"unusual_txn_alerts_enabled"
BOOLEAN
NOT
NULL
DEFAULT
true
,
"created_at"
TIMESTAMP
(
3
)
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP
,
"updated_at"
TIMESTAMP
(
3
)
NOT
NULL
,
CONSTRAINT
"notification_settings_pkey"
PRIMARY
KEY
(
"id"
),
CONSTRAINT
"notification_settings_channels_not_empty_check"
CHECK
(
cardinality
(
"channels"
)
>
0
)
);
-- CreateTable
CREATE
TABLE
"reminders"
(
"id"
UUID
NOT
NULL
,
"user_id"
UUID
NOT
NULL
,
"type"
"ReminderType"
NOT
NULL
DEFAULT
'GENERAL'
,
"title"
VARCHAR
(
160
)
NOT
NULL
,
"message"
TEXT
,
"remind_at"
TIMESTAMP
(
3
)
NOT
NULL
,
"frequency"
"ReminderFrequency"
NOT
NULL
DEFAULT
'ONCE'
,
"repeat_interval"
INTEGER
NOT
NULL
DEFAULT
1
,
"end_at"
TIMESTAMP
(
3
),
"next_trigger_at"
TIMESTAMP
(
3
),
"last_triggered_at"
TIMESTAMP
(
3
),
"action_url"
VARCHAR
(
500
),
"is_active"
BOOLEAN
NOT
NULL
DEFAULT
true
,
"created_at"
TIMESTAMP
(
3
)
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP
,
"updated_at"
TIMESTAMP
(
3
)
NOT
NULL
,
CONSTRAINT
"reminders_pkey"
PRIMARY
KEY
(
"id"
),
CONSTRAINT
"reminders_repeat_interval_positive_check"
CHECK
(
"repeat_interval"
>
0
),
CONSTRAINT
"reminders_end_at_check"
CHECK
(
"end_at"
IS
NULL
OR
"end_at"
>
"remind_at"
),
CONSTRAINT
"reminders_once_end_at_check"
CHECK
(
"frequency"
<>
'ONCE'
OR
"end_at"
IS
NULL
),
CONSTRAINT
"reminders_active_trigger_check"
CHECK
(
NOT
"is_active"
OR
"next_trigger_at"
IS
NOT
NULL
)
);
-- CreateIndex
CREATE
UNIQUE
INDEX
"notifications_user_id_dedup_key_key"
ON
"notifications"
(
"user_id"
,
"dedup_key"
);
CREATE
INDEX
"notifications_user_id_created_at_idx"
ON
"notifications"
(
"user_id"
,
"created_at"
);
CREATE
INDEX
"notifications_user_id_read_at_created_at_idx"
ON
"notifications"
(
"user_id"
,
"read_at"
,
"created_at"
);
CREATE
INDEX
"notifications_source_type_source_id_idx"
ON
"notifications"
(
"source_type"
,
"source_id"
);
CREATE
UNIQUE
INDEX
"notification_deliveries_notification_id_channel_key"
ON
"notification_deliveries"
(
"notification_id"
,
"channel"
);
CREATE
INDEX
"notification_deliveries_status_next_attempt_at_idx"
ON
"notification_deliveries"
(
"status"
,
"next_attempt_at"
);
CREATE
UNIQUE
INDEX
"notification_settings_user_id_key"
ON
"notification_settings"
(
"user_id"
);
CREATE
INDEX
"reminders_user_id_is_active_next_trigger_at_idx"
ON
"reminders"
(
"user_id"
,
"is_active"
,
"next_trigger_at"
);
CREATE
INDEX
"reminders_next_trigger_at_is_active_idx"
ON
"reminders"
(
"next_trigger_at"
,
"is_active"
);
-- AddForeignKey
ALTER
TABLE
"notifications"
ADD
CONSTRAINT
"notifications_user_id_fkey"
FOREIGN
KEY
(
"user_id"
)
REFERENCES
"users"
(
"id"
)
ON
DELETE
CASCADE
ON
UPDATE
CASCADE
;
ALTER
TABLE
"notification_deliveries"
ADD
CONSTRAINT
"notification_deliveries_notification_id_fkey"
FOREIGN
KEY
(
"notification_id"
)
REFERENCES
"notifications"
(
"id"
)
ON
DELETE
CASCADE
ON
UPDATE
CASCADE
;
ALTER
TABLE
"notification_settings"
ADD
CONSTRAINT
"notification_settings_user_id_fkey"
FOREIGN
KEY
(
"user_id"
)
REFERENCES
"users"
(
"id"
)
ON
DELETE
CASCADE
ON
UPDATE
CASCADE
;
ALTER
TABLE
"reminders"
ADD
CONSTRAINT
"reminders_user_id_fkey"
FOREIGN
KEY
(
"user_id"
)
REFERENCES
"users"
(
"id"
)
ON
DELETE
CASCADE
ON
UPDATE
CASCADE
;
prisma/schema.prisma
View file @
132dba7b
...
...
@@ -30,6 +30,61 @@ enum SavingGoalStatus {
COMPLETED
}
enum
NotificationType
{
BUDGET_NEAR_LIMIT
BUDGET_EXCEEDED
SAVING_GOAL_NEAR_TARGET
SAVING_GOAL_ACHIEVED
SAVING_GOAL_DUE_SOON
RECURRING_PAYMENT_DUE
UNUSUAL_TRANSACTION
USER_REMINDER
SYSTEM
}
enum
NotificationPriority
{
LOW
NORMAL
HIGH
CRITICAL
}
enum
NotificationChannel
{
IN_APP
EMAIL
ZALO
PUSH
}
enum
NotificationDeliveryStatus
{
PENDING
PROCESSING
SENT
FAILED
SKIPPED
}
enum
NotificationSourceType
{
BUDGET
SAVING_GOAL
TRANSACTION
REMINDER
SYSTEM
}
enum
ReminderType
{
GENERAL
RECURRING_PAYMENT
}
enum
ReminderFrequency
{
ONCE
DAILY
WEEKLY
MONTHLY
YEARLY
}
model
User
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
email
String
?
@
unique
...
...
@@ -56,6 +111,9 @@ model User {
verificationTokens
VerificationToken
[]
passwordResetTokens
PasswordResetToken
[]
devices
UserDevice
[]
notifications
Notification
[]
notificationSetting
NotificationSetting
?
reminders
Reminder
[]
@@
index
([
roleId
])
@@
map
(
"users"
)
...
...
@@ -282,3 +340,91 @@ model UserDevice {
@@
unique
([
userId
,
deviceHash
])
@@
map
(
"user_devices"
)
}
model
Notification
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
userId
String
@
map
(
"user_id"
)
@
db
.
Uuid
type
NotificationType
priority
NotificationPriority
@
default
(
NORMAL
)
title
String
@
db
.
VarChar
(
160
)
message
String
@
db
.
Text
channels
NotificationChannel
[]
@
default
([
IN_APP
])
data
Json
?
actionUrl
String
?
@
map
(
"action_url"
)
@
db
.
VarChar
(
500
)
sourceType
NotificationSourceType
?
@
map
(
"source_type"
)
sourceId
String
?
@
map
(
"source_id"
)
@
db
.
Uuid
dedupKey
String
@
map
(
"dedup_key"
)
@
db
.
VarChar
(
255
)
readAt
DateTime
?
@
map
(
"read_at"
)
expiresAt
DateTime
?
@
map
(
"expires_at"
)
createdAt
DateTime
@
default
(
now
())
@
map
(
"created_at"
)
updatedAt
DateTime
@
updatedAt
@
map
(
"updated_at"
)
user
User
@
relation
(
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
Cascade
)
deliveries
NotificationDelivery
[]
@@
unique
([
userId
,
dedupKey
])
@@
index
([
userId
,
createdAt
])
@@
index
([
userId
,
readAt
,
createdAt
])
@@
index
([
sourceType
,
sourceId
])
@@
map
(
"notifications"
)
}
model
NotificationDelivery
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
notificationId
String
@
map
(
"notification_id"
)
@
db
.
Uuid
channel
NotificationChannel
status
NotificationDeliveryStatus
@
default
(
PENDING
)
attemptCount
Int
@
default
(
0
)
@
map
(
"attempt_count"
)
nextAttemptAt
DateTime
@
default
(
now
())
@
map
(
"next_attempt_at"
)
sentAt
DateTime
?
@
map
(
"sent_at"
)
failureReason
String
?
@
map
(
"failure_reason"
)
@
db
.
VarChar
(
500
)
providerMessageId
String
?
@
map
(
"provider_message_id"
)
@
db
.
VarChar
(
255
)
createdAt
DateTime
@
default
(
now
())
@
map
(
"created_at"
)
updatedAt
DateTime
@
updatedAt
@
map
(
"updated_at"
)
notification
Notification
@
relation
(
fields
:
[
notificationId
],
references
:
[
id
],
onDelete
:
Cascade
)
@@
unique
([
notificationId
,
channel
])
@@
index
([
status
,
nextAttemptAt
])
@@
map
(
"notification_deliveries"
)
}
model
NotificationSetting
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
userId
String
@
unique
@
map
(
"user_id"
)
@
db
.
Uuid
channels
NotificationChannel
[]
@
default
([
IN_APP
])
budgetAlertsEnabled
Boolean
@
default
(
true
)
@
map
(
"budget_alerts_enabled"
)
savingGoalAlertsEnabled
Boolean
@
default
(
true
)
@
map
(
"saving_goal_alerts_enabled"
)
reminderAlertsEnabled
Boolean
@
default
(
true
)
@
map
(
"reminder_alerts_enabled"
)
unusualTxnAlertsEnabled
Boolean
@
default
(
true
)
@
map
(
"unusual_txn_alerts_enabled"
)
createdAt
DateTime
@
default
(
now
())
@
map
(
"created_at"
)
updatedAt
DateTime
@
updatedAt
@
map
(
"updated_at"
)
user
User
@
relation
(
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
Cascade
)
@@
map
(
"notification_settings"
)
}
model
Reminder
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
userId
String
@
map
(
"user_id"
)
@
db
.
Uuid
type
ReminderType
@
default
(
GENERAL
)
title
String
@
db
.
VarChar
(
160
)
message
String
?
@
db
.
Text
remindAt
DateTime
@
map
(
"remind_at"
)
frequency
ReminderFrequency
@
default
(
ONCE
)
repeatInterval
Int
@
default
(
1
)
@
map
(
"repeat_interval"
)
endAt
DateTime
?
@
map
(
"end_at"
)
nextTriggerAt
DateTime
?
@
map
(
"next_trigger_at"
)
lastTriggeredAt
DateTime
?
@
map
(
"last_triggered_at"
)
actionUrl
String
?
@
map
(
"action_url"
)
@
db
.
VarChar
(
500
)
isActive
Boolean
@
default
(
true
)
@
map
(
"is_active"
)
createdAt
DateTime
@
default
(
now
())
@
map
(
"created_at"
)
updatedAt
DateTime
@
updatedAt
@
map
(
"updated_at"
)
user
User
@
relation
(
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
Cascade
)
@@
index
([
userId
,
isActive
,
nextTriggerAt
])
@@
index
([
nextTriggerAt
,
isActive
])
@@
map
(
"reminders"
)
}
src/common/errors/error-code.ts
View file @
132dba7b
...
...
@@ -23,6 +23,7 @@ export const ERROR_CODE = {
SAVING_GOAL_PAUSED
:
'SAVING_GOAL_PAUSED'
,
SAVING_GOAL_COMPLETED
:
'SAVING_GOAL_COMPLETED'
,
SAVING_GOAL_CURRENCY_LOCKED
:
'SAVING_GOAL_CURRENCY_LOCKED'
,
REMINDER_SCHEDULE_INVALID
:
'REMINDER_SCHEDULE_INVALID'
,
FILE_TYPE_UNSUPPORTED
:
'FILE_TYPE_UNSUPPORTED'
,
FILE_TOO_LARGE
:
'FILE_TOO_LARGE'
,
RECEIPT_NOT_FOUND
:
'RECEIPT_NOT_FOUND'
,
...
...
src/common/services/mail.service.ts
View file @
132dba7b
...
...
@@ -144,4 +144,43 @@ export class MailService {
throw
error
;
}
}
async
sendNotificationEmail
(
email
:
string
,
notification
:
{
title
:
string
;
message
:
string
;
actionUrl
:
string
|
null
;
},
fullName
?:
string
|
null
,
)
{
const
escapeHtml
=
(
value
:
string
)
=>
value
.
replace
(
/&/g
,
'&'
)
.
replace
(
/</g
,
'<'
)
.
replace
(
/>/g
,
'>'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/'/g
,
'''
);
const
actionUrl
=
notification
.
actionUrl
?
notification
.
actionUrl
.
startsWith
(
'/'
)
?
`
${
mailConfig
.
appUrl
}${
notification
.
actionUrl
}
`
:
notification
.
actionUrl
:
null
;
const
actionHtml
=
actionUrl
?
`<p style="text-align: center; margin-top: 24px;"><a href="
${
escapeHtml
(
actionUrl
)}
" style="background-color: #2563eb; color: white; padding: 10px 18px; text-decoration: none; border-radius: 5px;">Open FinWise</a></p>`
:
''
;
await
this
.
transporter
.
sendMail
({
from
:
mailConfig
.
from
,
to
:
email
,
subject
:
`[FinWise]
${
notification
.
title
.
replace
(
/
[\r\n]
+/g
,
' '
)}
`
,
html
:
`
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<p>Hello
${
escapeHtml
(
fullName
||
'there'
)}
,</p>
<h2>
${
escapeHtml
(
notification
.
title
)}
</h2>
<p>
${
escapeHtml
(
notification
.
message
)}
</p>
${
actionHtml
}
</div>
`
,
});
}
}
src/config/env.config.ts
View file @
132dba7b
...
...
@@ -41,4 +41,22 @@ export const envConfig = {
return
this
.
maxFileSizeMb
*
1024
*
1024
;
},
},
notifications
:
{
workerEnabled
:
process
.
env
.
NOTIFICATION_WORKER_ENABLED
!==
'false'
,
workerIntervalMs
:
(()
=>
{
const
value
=
parseInt
(
process
.
env
.
NOTIFICATION_WORKER_INTERVAL_MS
||
'60000'
,
10
);
return
Number
.
isFinite
(
value
)
&&
value
>=
5000
&&
value
<=
3600000
?
value
:
60000
;
})(),
financialScanIntervalMs
:
(()
=>
{
const
value
=
parseInt
(
process
.
env
.
NOTIFICATION_FINANCIAL_SCAN_INTERVAL_MS
||
'300000'
,
10
,
);
return
Number
.
isFinite
(
value
)
&&
value
>=
60000
&&
value
<=
86400000
?
value
:
300000
;
})(),
},
};
src/config/mail.config.ts
View file @
132dba7b
const
appUrl
=
process
.
env
.
APP_URL
||
'http://localhost:7777'
;
export
const
mailConfig
=
{
appUrl
,
host
:
process
.
env
.
MAIL_HOST
||
'smtp.gmail.com'
,
port
:
parseInt
(
process
.
env
.
MAIL_PORT
||
'587'
,
10
),
secure
:
process
.
env
.
MAIL_SECURE
===
'true'
,
// true for 465, false for 587
...
...
src/config/swagger.config.ts
View file @
132dba7b
This diff is collapsed.
Click to expand it.
src/modules/notifications/notification-delivery.service.ts
0 → 100644
View file @
132dba7b
import
{
NotificationChannel
,
NotificationDeliveryStatus
,
}
from
'@prisma/client'
;
import
{
MailService
}
from
'../../common/services/mail.service'
;
import
{
mailConfig
}
from
'../../config/mail.config'
;
import
{
NotificationRepository
}
from
'./notification.repository'
;
export
class
NotificationDeliveryService
{
private
readonly
repository
=
new
NotificationRepository
();
private
readonly
mailService
=
new
MailService
();
async
processDue
(
now
:
Date
)
{
const
staleBefore
=
new
Date
(
now
.
getTime
()
-
5
*
60
*
1000
);
const
deliveries
=
await
this
.
repository
.
findDueDeliveries
(
now
,
staleBefore
);
for
(
const
delivery
of
deliveries
)
{
const
claimed
=
await
this
.
repository
.
claimDelivery
(
delivery
.
id
,
now
,
staleBefore
,
);
if
(
!
claimed
)
{
continue
;
}
try
{
const
skippedReason
=
await
this
.
deliver
(
claimed
);
await
this
.
repository
.
completeDelivery
(
claimed
.
id
,
skippedReason
?
NotificationDeliveryStatus
.
SKIPPED
:
NotificationDeliveryStatus
.
SENT
,
skippedReason
??
undefined
,
);
}
catch
(
_error
)
{
const
retryDelay
=
Math
.
min
(
2
**
claimed
.
attemptCount
*
60
*
1000
,
60
*
60
*
1000
,
);
await
this
.
repository
.
failDelivery
(
claimed
.
id
,
new
Date
(
Date
.
now
()
+
retryDelay
),
'Delivery attempt failed'
,
);
console
.
error
(
`Notification delivery
${
claimed
.
id
}
failed`
);
}
}
return
deliveries
.
length
;
}
private
async
deliver
(
delivery
:
NonNullable
<
Awaited
<
ReturnType
<
NotificationRepository
[
'claimDelivery'
]
>>>
)
{
if
(
delivery
.
channel
===
NotificationChannel
.
EMAIL
)
{
if
(
!
mailConfig
.
auth
.
user
||
!
mailConfig
.
auth
.
pass
)
{
return
'Email provider is not configured'
;
}
if
(
!
delivery
.
notification
.
user
.
email
)
{
return
'User has no email address'
;
}
await
this
.
mailService
.
sendNotificationEmail
(
delivery
.
notification
.
user
.
email
,
delivery
.
notification
,
delivery
.
notification
.
user
.
fullName
,
);
return
null
;
}
if
(
delivery
.
channel
===
NotificationChannel
.
ZALO
)
{
return
'Zalo provider is not configured'
;
}
if
(
delivery
.
channel
===
NotificationChannel
.
PUSH
)
{
return
'Push provider is not configured'
;
}
return
'In-app notifications do not require delivery jobs'
;
}
}
src/modules/notifications/notification.controller.ts
0 → 100644
View file @
132dba7b
import
{
NextFunction
,
Request
,
Response
}
from
'express'
;
import
{
NotificationQueryDto
,
UpdateNotificationSettingDto
,
}
from
'./notification.dto'
;
import
{
NotificationService
}
from
'./notification.service'
;
export
class
NotificationController
{
private
readonly
service
=
new
NotificationService
();
findAll
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
result
=
await
this
.
service
.
findAll
(
req
.
user
.
id
,
req
.
query
as
unknown
as
NotificationQueryDto
,
);
res
.
json
({
success
:
true
,
...
result
});
}
catch
(
error
)
{
next
(
error
);
}
};
unreadCount
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
count
=
await
this
.
service
.
unreadCount
(
req
.
user
.
id
);
res
.
json
({
success
:
true
,
data
:
{
count
}
});
}
catch
(
error
)
{
next
(
error
);
}
};
markRead
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
notification
=
await
this
.
service
.
markRead
(
req
.
user
.
id
,
req
.
params
.
id
);
res
.
json
({
success
:
true
,
data
:
notification
});
}
catch
(
error
)
{
next
(
error
);
}
};
markAllRead
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
count
=
await
this
.
service
.
markAllRead
(
req
.
user
.
id
);
res
.
json
({
success
:
true
,
data
:
{
count
}
});
}
catch
(
error
)
{
next
(
error
);
}
};
remove
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
await
this
.
service
.
remove
(
req
.
user
.
id
,
req
.
params
.
id
);
res
.
json
({
success
:
true
,
data
:
{
id
:
req
.
params
.
id
}
});
}
catch
(
error
)
{
next
(
error
);
}
};
getSetting
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
setting
=
await
this
.
service
.
getSetting
(
req
.
user
.
id
);
res
.
json
({
success
:
true
,
data
:
setting
});
}
catch
(
error
)
{
next
(
error
);
}
};
updateSetting
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
setting
=
await
this
.
service
.
updateSetting
(
req
.
user
.
id
,
req
.
body
as
UpdateNotificationSettingDto
,
);
res
.
json
({
success
:
true
,
data
:
setting
});
}
catch
(
error
)
{
next
(
error
);
}
};
}
src/modules/notifications/notification.dto.ts
0 → 100644
View file @
132dba7b
import
{
NotificationChannel
,
NotificationPriority
,
NotificationSourceType
,
NotificationType
,
}
from
'@prisma/client'
;
export
interface
NotificationQueryDto
{
type
?:
NotificationType
;
priority
?:
NotificationPriority
;
isRead
?:
boolean
;
page
:
number
;
limit
:
number
;
}
export
interface
UpdateNotificationSettingDto
{
channels
?:
NotificationChannel
[];
budgetAlertsEnabled
?:
boolean
;
savingGoalAlertsEnabled
?:
boolean
;
reminderAlertsEnabled
?:
boolean
;
unusualTxnAlertsEnabled
?:
boolean
;
}
export
interface
CreateNotificationInput
{
userId
:
string
;
type
:
NotificationType
;
priority
?:
NotificationPriority
;
title
:
string
;
message
:
string
;
actionUrl
?:
string
|
null
;
sourceType
?:
NotificationSourceType
|
null
;
sourceId
?:
string
|
null
;
data
?:
Record
<
string
,
string
|
number
|
boolean
|
null
>
;
dedupKey
:
string
;
expiresAt
?:
Date
|
null
;
}
export
interface
NotificationSettingDto
{
channels
:
NotificationChannel
[];
budgetAlertsEnabled
:
boolean
;
savingGoalAlertsEnabled
:
boolean
;
reminderAlertsEnabled
:
boolean
;
unusualTxnAlertsEnabled
:
boolean
;
}
export
const
DEFAULT_NOTIFICATION_SETTING
:
NotificationSettingDto
=
{
channels
:
[
NotificationChannel
.
IN_APP
],
budgetAlertsEnabled
:
true
,
savingGoalAlertsEnabled
:
true
,
reminderAlertsEnabled
:
true
,
unusualTxnAlertsEnabled
:
true
,
};
src/modules/notifications/notification.repository.ts
0 → 100644
View file @
132dba7b
This diff is collapsed.
Click to expand it.
src/modules/notifications/notification.route.ts
0 → 100644
View file @
132dba7b
import
{
Router
}
from
'express'
;
import
{
authMiddleware
}
from
'../../middlewares/auth.middleware'
;
import
{
validate
}
from
'../../middlewares/validate.middleware'
;
import
{
NotificationController
}
from
'./notification.controller'
;
import
{
findNotificationsSchema
,
notificationParamsSchema
,
updateNotificationSettingSchema
,
}
from
'./notification.validation'
;
const
router
=
Router
();
const
controller
=
new
NotificationController
();
router
.
use
(
authMiddleware
);
router
.
get
(
'/'
,
validate
(
findNotificationsSchema
,
'query'
),
controller
.
findAll
);
router
.
get
(
'/unread-count'
,
controller
.
unreadCount
);
router
.
patch
(
'/read-all'
,
controller
.
markAllRead
);
router
.
get
(
'/settings'
,
controller
.
getSetting
);
router
.
put
(
'/settings'
,
validate
(
updateNotificationSettingSchema
),
controller
.
updateSetting
,
);
router
.
patch
(
'/:id/read'
,
validate
(
notificationParamsSchema
,
'params'
),
controller
.
markRead
,
);
router
.
delete
(
'/:id'
,
validate
(
notificationParamsSchema
,
'params'
),
controller
.
remove
);
export
default
router
;
src/modules/notifications/notification.service.ts
0 → 100644
View file @
132dba7b
import
{
NotificationPriority
,
NotificationSourceType
,
NotificationType
,
Prisma
,
SavingGoalStatus
,
TransactionType
,
}
from
'@prisma/client'
;
import
{
AppError
}
from
'../../common/errors/app-error'
;
import
{
ERROR_CODE
}
from
'../../common/errors/error-code'
;
import
{
CreateNotificationInput
,
DEFAULT_NOTIFICATION_SETTING
,
NotificationQueryDto
,
NotificationSettingDto
,
UpdateNotificationSettingDto
,
}
from
'./notification.dto'
;
import
{
BudgetAlertCandidate
,
NotificationRepository
,
SavingGoalAlertCandidate
,
}
from
'./notification.repository'
;
const
GOAL_NEAR_TARGET_PERCENT
=
new
Prisma
.
Decimal
(
80
);
const
GOAL_DUE_SOON_DAYS
=
7
;
const
ANOMALY_MIN_HISTORY_COUNT
=
5
;
const
ANOMALY_AVERAGE_MULTIPLIER
=
new
Prisma
.
Decimal
(
3
);
interface
TransactionForAnomalyCheck
{
id
:
string
;
walletId
:
string
;
amount
:
string
;
type
:
TransactionType
;
date
:
Date
;
wallet
:
{
currency
:
string
};
}
export
class
NotificationService
{
private
readonly
repository
=
new
NotificationRepository
();
findAll
(
userId
:
string
,
query
:
NotificationQueryDto
)
{
return
this
.
repository
.
findAll
(
userId
,
query
);
}
unreadCount
(
userId
:
string
)
{
return
this
.
repository
.
unreadCount
(
userId
);
}
async
markRead
(
userId
:
string
,
id
:
string
)
{
const
notification
=
await
this
.
findOwned
(
userId
,
id
);
return
notification
.
readAt
?
notification
:
this
.
repository
.
markRead
(
notification
.
id
);
}
async
markAllRead
(
userId
:
string
)
{
return
this
.
repository
.
markAllRead
(
userId
);
}
async
remove
(
userId
:
string
,
id
:
string
)
{
await
this
.
findOwned
(
userId
,
id
);
return
this
.
repository
.
remove
(
id
);
}
async
getSetting
(
userId
:
string
):
Promise
<
NotificationSettingDto
>
{
return
(
await
this
.
repository
.
getSetting
(
userId
))
??
DEFAULT_NOTIFICATION_SETTING
;
}
updateSetting
(
userId
:
string
,
data
:
UpdateNotificationSettingDto
)
{
return
this
.
repository
.
updateSetting
(
userId
,
data
);
}
async
create
(
input
:
CreateNotificationInput
)
{
const
setting
=
await
this
.
getSetting
(
input
.
userId
);
if
(
!
this
.
isEnabled
(
input
.
type
,
setting
))
{
return
null
;
}
return
this
.
repository
.
createIfAbsent
(
input
,
setting
.
channels
);
}
async
getChannelsForType
(
type
:
NotificationType
,
userId
:
string
)
{
const
setting
=
await
this
.
getSetting
(
userId
);
return
this
.
isEnabled
(
type
,
setting
)
?
setting
.
channels
:
null
;
}
async
scanFinancialAlerts
(
now
:
Date
)
{
let
budgetCursor
:
string
|
undefined
;
do
{
const
candidates
=
await
this
.
repository
.
findBudgetCandidates
(
now
,
budgetCursor
);
for
(
const
candidate
of
candidates
)
{
await
this
.
processBudgetCandidate
(
candidate
);
}
budgetCursor
=
candidates
.
length
===
100
?
candidates
[
candidates
.
length
-
1
].
id
:
undefined
;
}
while
(
budgetCursor
);
let
goalCursor
:
string
|
undefined
;
do
{
const
candidates
=
await
this
.
repository
.
findSavingGoalCandidates
(
goalCursor
);
for
(
const
candidate
of
candidates
)
{
await
this
.
processSavingGoalCandidate
(
candidate
,
now
);
}
goalCursor
=
candidates
.
length
===
100
?
candidates
[
candidates
.
length
-
1
].
id
:
undefined
;
}
while
(
goalCursor
);
}
async
processBudgetCandidate
(
candidate
:
BudgetAlertCandidate
)
{
const
usage
=
candidate
.
spentAmount
.
dividedBy
(
candidate
.
amount
)
.
times
(
100
);
const
keySuffix
=
`
${
candidate
.
startDate
.
toISOString
()}
:
${
candidate
.
amount
.
toFixed
(
2
)}
`
;
const
common
=
{
userId
:
candidate
.
userId
,
sourceType
:
NotificationSourceType
.
BUDGET
,
sourceId
:
candidate
.
id
,
actionUrl
:
`/budgets/
${
candidate
.
id
}
`
,
data
:
{
budgetId
:
candidate
.
id
,
spentAmount
:
candidate
.
spentAmount
.
toFixed
(
2
),
budgetAmount
:
candidate
.
amount
.
toFixed
(
2
),
currency
:
candidate
.
currency
,
usagePercentage
:
usage
.
toFixed
(
2
),
},
};
if
(
candidate
.
spentAmount
.
greaterThan
(
candidate
.
amount
))
{
return
this
.
create
({
...
common
,
type
:
NotificationType
.
BUDGET_EXCEEDED
,
priority
:
NotificationPriority
.
CRITICAL
,
title
:
`Budget exceeded:
${
candidate
.
name
}
`
,
message
:
`Spending has reached
${
usage
.
toFixed
(
0
)}
% of this budget.`
,
dedupKey
:
`budget:
${
candidate
.
id
}
:exceeded:
${
keySuffix
}
`
,
});
}
if
(
usage
.
greaterThanOrEqualTo
(
candidate
.
alertThreshold
))
{
return
this
.
create
({
...
common
,
type
:
NotificationType
.
BUDGET_NEAR_LIMIT
,
priority
:
NotificationPriority
.
HIGH
,
title
:
`Budget nearing limit:
${
candidate
.
name
}
`
,
message
:
`Spending has reached
${
usage
.
toFixed
(
0
)}
% of this budget.`
,
dedupKey
:
`budget:
${
candidate
.
id
}
:near:
${
keySuffix
}
`
,
});
}
return
null
;
}
async
processSavingGoalCandidate
(
candidate
:
SavingGoalAlertCandidate
,
now
:
Date
)
{
const
progress
=
candidate
.
savedAmount
.
dividedBy
(
candidate
.
targetAmount
)
.
times
(
100
);
const
common
=
{
userId
:
candidate
.
userId
,
sourceType
:
NotificationSourceType
.
SAVING_GOAL
,
sourceId
:
candidate
.
id
,
actionUrl
:
`/saving-goals/
${
candidate
.
id
}
`
,
data
:
{
savingGoalId
:
candidate
.
id
,
savedAmount
:
candidate
.
savedAmount
.
toFixed
(
2
),
targetAmount
:
candidate
.
targetAmount
.
toFixed
(
2
),
currency
:
candidate
.
currency
,
progressPercentage
:
progress
.
toFixed
(
2
),
},
};
const
targetKey
=
candidate
.
targetAmount
.
toFixed
(
2
);
if
(
candidate
.
status
===
SavingGoalStatus
.
COMPLETED
||
candidate
.
savedAmount
.
greaterThanOrEqualTo
(
candidate
.
targetAmount
)
)
{
await
this
.
create
({
...
common
,
type
:
NotificationType
.
SAVING_GOAL_ACHIEVED
,
priority
:
NotificationPriority
.
HIGH
,
title
:
`Saving goal achieved:
${
candidate
.
name
}
`
,
message
:
'Congratulations! You have reached this saving goal.'
,
dedupKey
:
`saving-goal:
${
candidate
.
id
}
:achieved:
${
targetKey
}
`
,
});
return
;
}
if
(
progress
.
greaterThanOrEqualTo
(
GOAL_NEAR_TARGET_PERCENT
))
{
await
this
.
create
({
...
common
,
type
:
NotificationType
.
SAVING_GOAL_NEAR_TARGET
,
priority
:
NotificationPriority
.
NORMAL
,
title
:
`Saving goal almost reached:
${
candidate
.
name
}
`
,
message
:
`You have completed
${
progress
.
toFixed
(
0
)}
% of this saving goal.`
,
dedupKey
:
`saving-goal:
${
candidate
.
id
}
:near:
${
targetKey
}
`
,
});
}
const
dueSoonAt
=
new
Date
(
now
);
dueSoonAt
.
setUTCDate
(
dueSoonAt
.
getUTCDate
()
+
GOAL_DUE_SOON_DAYS
);
if
(
candidate
.
targetDate
>=
now
&&
candidate
.
targetDate
<=
dueSoonAt
)
{
await
this
.
create
({
...
common
,
type
:
NotificationType
.
SAVING_GOAL_DUE_SOON
,
priority
:
NotificationPriority
.
HIGH
,
title
:
`Saving goal deadline approaching:
${
candidate
.
name
}
`
,
message
:
`The target date is
${
candidate
.
targetDate
.
toISOString
()}
.`
,
dedupKey
:
`saving-goal:
${
candidate
.
id
}
:due:
${
candidate
.
targetDate
.
toISOString
()}
`
,
});
}
}
async
detectUnusualTransaction
(
userId
:
string
,
transaction
:
TransactionForAnomalyCheck
)
{
if
(
transaction
.
type
!==
TransactionType
.
EXPENSE
)
{
return
;
}
try
{
const
baseline
=
await
this
.
repository
.
getExpenseBaseline
(
userId
,
transaction
.
id
,
transaction
.
walletId
,
transaction
.
date
,
);
const
amount
=
new
Prisma
.
Decimal
(
transaction
.
amount
);
if
(
baseline
.
count
<
ANOMALY_MIN_HISTORY_COUNT
||
baseline
.
average
.
lessThanOrEqualTo
(
0
)
||
amount
.
lessThan
(
baseline
.
average
.
times
(
ANOMALY_AVERAGE_MULTIPLIER
))
)
{
return
;
}
await
this
.
create
({
userId
,
type
:
NotificationType
.
UNUSUAL_TRANSACTION
,
priority
:
NotificationPriority
.
CRITICAL
,
title
:
'Unusual transaction detected'
,
message
:
'This expense is significantly higher than your recent spending in the same wallet.'
,
sourceType
:
NotificationSourceType
.
TRANSACTION
,
sourceId
:
transaction
.
id
,
actionUrl
:
`/transactions/
${
transaction
.
id
}
`
,
data
:
{
transactionId
:
transaction
.
id
,
amount
:
transaction
.
amount
,
currency
:
transaction
.
wallet
.
currency
,
recentAverage
:
baseline
.
average
.
toFixed
(
2
),
},
dedupKey
:
`transaction:
${
transaction
.
id
}
:unusual`
,
});
}
catch
(
error
)
{
console
.
error
(
'Failed to evaluate unusual transaction notification'
,
error
);
}
}
private
async
findOwned
(
userId
:
string
,
id
:
string
)
{
const
notification
=
await
this
.
repository
.
findById
(
userId
,
id
);
if
(
!
notification
)
{
throw
new
AppError
(
'Notification not found'
,
404
,
ERROR_CODE
.
NOT_FOUND
);
}
return
notification
;
}
private
isEnabled
(
type
:
NotificationType
,
setting
:
NotificationSettingDto
)
{
if
(
type
===
NotificationType
.
BUDGET_NEAR_LIMIT
||
type
===
NotificationType
.
BUDGET_EXCEEDED
)
{
return
setting
.
budgetAlertsEnabled
;
}
if
(
type
===
NotificationType
.
SAVING_GOAL_NEAR_TARGET
||
type
===
NotificationType
.
SAVING_GOAL_ACHIEVED
||
type
===
NotificationType
.
SAVING_GOAL_DUE_SOON
)
{
return
setting
.
savingGoalAlertsEnabled
;
}
if
(
type
===
NotificationType
.
USER_REMINDER
||
type
===
NotificationType
.
RECURRING_PAYMENT_DUE
)
{
return
setting
.
reminderAlertsEnabled
;
}
if
(
type
===
NotificationType
.
UNUSUAL_TRANSACTION
)
{
return
setting
.
unusualTxnAlertsEnabled
;
}
return
true
;
}
}
src/modules/notifications/notification.validation.ts
0 → 100644
View file @
132dba7b
import
{
z
}
from
'zod'
;
const
notificationTypeSchema
=
z
.
enum
([
'BUDGET_NEAR_LIMIT'
,
'BUDGET_EXCEEDED'
,
'SAVING_GOAL_NEAR_TARGET'
,
'SAVING_GOAL_ACHIEVED'
,
'SAVING_GOAL_DUE_SOON'
,
'RECURRING_PAYMENT_DUE'
,
'UNUSUAL_TRANSACTION'
,
'USER_REMINDER'
,
'SYSTEM'
,
]);
const
notificationPrioritySchema
=
z
.
enum
([
'LOW'
,
'NORMAL'
,
'HIGH'
,
'CRITICAL'
]);
const
notificationChannelSchema
=
z
.
enum
([
'IN_APP'
,
'EMAIL'
,
'ZALO'
,
'PUSH'
]);
export
const
notificationParamsSchema
=
z
.
object
({
id
:
z
.
string
().
uuid
(
'Invalid notification id'
),
});
export
const
findNotificationsSchema
=
z
.
object
({
type
:
notificationTypeSchema
.
optional
(),
priority
:
notificationPrioritySchema
.
optional
(),
isRead
:
z
.
enum
([
'true'
,
'false'
])
.
transform
((
value
)
=>
value
===
'true'
)
.
optional
(),
page
:
z
.
coerce
.
number
().
int
().
positive
().
optional
().
default
(
1
),
limit
:
z
.
coerce
.
number
().
int
().
min
(
1
).
max
(
100
).
optional
().
default
(
20
),
});
export
const
updateNotificationSettingSchema
=
z
.
object
({
channels
:
z
.
array
(
notificationChannelSchema
)
.
min
(
1
,
'At least one notification channel is required'
)
.
max
(
4
)
.
transform
((
channels
)
=>
[...
new
Set
(
channels
)])
.
optional
(),
budgetAlertsEnabled
:
z
.
boolean
().
optional
(),
savingGoalAlertsEnabled
:
z
.
boolean
().
optional
(),
reminderAlertsEnabled
:
z
.
boolean
().
optional
(),
unusualTxnAlertsEnabled
:
z
.
boolean
().
optional
(),
})
.
refine
((
data
)
=>
Object
.
keys
(
data
).
length
>
0
,
{
message
:
'At least one field is required'
,
});
src/modules/notifications/notification.worker.ts
0 → 100644
View file @
132dba7b
import
{
envConfig
}
from
'../../config/env.config'
;
import
{
ReminderService
}
from
'../reminders/reminder.service'
;
import
{
NotificationDeliveryService
}
from
'./notification-delivery.service'
;
import
{
NotificationService
}
from
'./notification.service'
;
export
class
NotificationWorker
{
private
readonly
reminderService
=
new
ReminderService
();
private
readonly
notificationService
=
new
NotificationService
();
private
readonly
deliveryService
=
new
NotificationDeliveryService
();
private
timer
:
NodeJS
.
Timeout
|
null
=
null
;
private
running
=
false
;
private
lastFinancialScanAt
=
0
;
start
()
{
if
(
!
envConfig
.
notifications
.
workerEnabled
||
this
.
timer
)
{
return
;
}
this
.
timer
=
setInterval
(
()
=>
void
this
.
tick
(),
envConfig
.
notifications
.
workerIntervalMs
,
);
this
.
timer
.
unref
();
void
this
.
tick
();
}
stop
()
{
if
(
this
.
timer
)
{
clearInterval
(
this
.
timer
);
this
.
timer
=
null
;
}
}
private
async
tick
()
{
if
(
this
.
running
)
{
return
;
}
this
.
running
=
true
;
const
now
=
new
Date
();
try
{
await
this
.
reminderService
.
processDue
(
now
);
}
catch
(
error
)
{
console
.
error
(
'Notification worker failed to process reminders'
,
error
);
}
try
{
await
this
.
deliveryService
.
processDue
(
now
);
}
catch
(
error
)
{
console
.
error
(
'Notification worker failed to process deliveries'
,
error
);
}
if
(
now
.
getTime
()
-
this
.
lastFinancialScanAt
>=
envConfig
.
notifications
.
financialScanIntervalMs
)
{
try
{
await
this
.
notificationService
.
scanFinancialAlerts
(
now
);
this
.
lastFinancialScanAt
=
now
.
getTime
();
}
catch
(
error
)
{
console
.
error
(
'Notification worker failed to scan financial alerts'
,
error
);
}
}
this
.
running
=
false
;
}
}
export
const
notificationWorker
=
new
NotificationWorker
();
src/modules/reminders/reminder.controller.ts
0 → 100644
View file @
132dba7b
import
{
NextFunction
,
Request
,
Response
}
from
'express'
;
import
{
CreateReminderDto
,
ReminderQueryDto
,
UpdateReminderDto
,
}
from
'./reminder.dto'
;
import
{
ReminderService
}
from
'./reminder.service'
;
export
class
ReminderController
{
private
readonly
service
=
new
ReminderService
();
findAll
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
result
=
await
this
.
service
.
findAll
(
req
.
user
.
id
,
req
.
query
as
unknown
as
ReminderQueryDto
,
);
res
.
json
({
success
:
true
,
...
result
});
}
catch
(
error
)
{
next
(
error
);
}
};
findById
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
reminder
=
await
this
.
service
.
findById
(
req
.
user
.
id
,
req
.
params
.
id
);
res
.
json
({
success
:
true
,
data
:
reminder
});
}
catch
(
error
)
{
next
(
error
);
}
};
create
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
reminder
=
await
this
.
service
.
create
(
req
.
user
.
id
,
req
.
body
as
CreateReminderDto
,
);
res
.
status
(
201
).
json
({
success
:
true
,
data
:
reminder
});
}
catch
(
error
)
{
next
(
error
);
}
};
update
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
reminder
=
await
this
.
service
.
update
(
req
.
user
.
id
,
req
.
params
.
id
,
req
.
body
as
UpdateReminderDto
,
);
res
.
json
({
success
:
true
,
data
:
reminder
});
}
catch
(
error
)
{
next
(
error
);
}
};
remove
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
await
this
.
service
.
remove
(
req
.
user
.
id
,
req
.
params
.
id
);
res
.
json
({
success
:
true
,
data
:
{
id
:
req
.
params
.
id
}
});
}
catch
(
error
)
{
next
(
error
);
}
};
}
src/modules/reminders/reminder.dto.ts
0 → 100644
View file @
132dba7b
import
{
ReminderFrequency
,
ReminderType
}
from
'@prisma/client'
;
export
interface
ReminderQueryDto
{
type
?:
ReminderType
;
isActive
?:
boolean
;
dueFrom
?:
Date
;
dueTo
?:
Date
;
page
:
number
;
limit
:
number
;
}
export
interface
CreateReminderDto
{
type
:
ReminderType
;
title
:
string
;
message
?:
string
|
null
;
remindAt
:
Date
;
frequency
:
ReminderFrequency
;
repeatInterval
:
number
;
endAt
?:
Date
|
null
;
actionUrl
?:
string
|
null
;
isActive
:
boolean
;
}
export
interface
UpdateReminderDto
{
type
?:
ReminderType
;
title
?:
string
;
message
?:
string
|
null
;
remindAt
?:
Date
;
frequency
?:
ReminderFrequency
;
repeatInterval
?:
number
;
endAt
?:
Date
|
null
;
actionUrl
?:
string
|
null
;
isActive
?:
boolean
;
}
export
interface
PersistReminderDto
{
type
:
ReminderType
;
title
:
string
;
message
:
string
|
null
;
remindAt
:
Date
;
frequency
:
ReminderFrequency
;
repeatInterval
:
number
;
endAt
:
Date
|
null
;
nextTriggerAt
:
Date
|
null
;
actionUrl
:
string
|
null
;
isActive
:
boolean
;
}
src/modules/reminders/reminder.repository.ts
0 → 100644
View file @
132dba7b
import
{
NotificationChannel
,
NotificationPriority
,
NotificationSourceType
,
NotificationType
,
Prisma
,
ReminderType
,
}
from
'@prisma/client'
;
import
{
prisma
}
from
'../../database/prisma.client'
;
import
{
PersistReminderDto
,
ReminderQueryDto
,
}
from
'./reminder.dto'
;
const
reminderSelect
=
{
id
:
true
,
userId
:
true
,
type
:
true
,
title
:
true
,
message
:
true
,
remindAt
:
true
,
frequency
:
true
,
repeatInterval
:
true
,
endAt
:
true
,
nextTriggerAt
:
true
,
lastTriggeredAt
:
true
,
actionUrl
:
true
,
isActive
:
true
,
createdAt
:
true
,
updatedAt
:
true
,
}
satisfies
Prisma
.
ReminderSelect
;
export
type
ReminderRecord
=
Prisma
.
ReminderGetPayload
<
{
select
:
typeof
reminderSelect
;
}
>
;
export
class
ReminderRepository
{
async
findAll
(
userId
:
string
,
query
:
ReminderQueryDto
)
{
const
{
type
,
isActive
,
dueFrom
,
dueTo
,
page
,
limit
}
=
query
;
const
where
:
Prisma
.
ReminderWhereInput
=
{
userId
,
...(
type
?
{
type
}
:
{}),
...(
isActive
!==
undefined
?
{
isActive
}
:
{}),
...(
dueFrom
||
dueTo
?
{
nextTriggerAt
:
{
...(
dueFrom
?
{
gte
:
dueFrom
}
:
{}),
...(
dueTo
?
{
lte
:
dueTo
}
:
{}),
},
}
:
{}),
};
const
skip
=
(
page
-
1
)
*
limit
;
const
[
reminders
,
total
]
=
await
prisma
.
$transaction
([
prisma
.
reminder
.
findMany
({
where
,
select
:
reminderSelect
,
orderBy
:
[{
nextTriggerAt
:
'asc'
},
{
createdAt
:
'desc'
}],
skip
,
take
:
limit
,
}),
prisma
.
reminder
.
count
({
where
}),
]);
return
{
data
:
reminders
,
meta
:
{
total
,
page
,
limit
,
totalPages
:
Math
.
ceil
(
total
/
limit
),
},
};
}
findById
(
userId
:
string
,
id
:
string
)
{
return
prisma
.
reminder
.
findFirst
({
where
:
{
id
,
userId
},
select
:
reminderSelect
,
});
}
create
(
userId
:
string
,
data
:
PersistReminderDto
)
{
return
prisma
.
reminder
.
create
({
data
:
{
userId
,
...
data
},
select
:
reminderSelect
,
});
}
update
(
id
:
string
,
data
:
PersistReminderDto
)
{
return
prisma
.
reminder
.
update
({
where
:
{
id
},
data
,
select
:
reminderSelect
,
});
}
remove
(
id
:
string
)
{
return
prisma
.
reminder
.
delete
({
where
:
{
id
},
select
:
{
id
:
true
}
});
}
findDue
(
now
:
Date
,
limit
:
number
=
100
)
{
return
prisma
.
reminder
.
findMany
({
where
:
{
isActive
:
true
,
nextTriggerAt
:
{
lte
:
now
},
},
select
:
reminderSelect
,
orderBy
:
[{
nextTriggerAt
:
'asc'
},
{
id
:
'asc'
}],
take
:
limit
,
});
}
async
triggerDue
(
reminder
:
ReminderRecord
,
triggeredAt
:
Date
,
nextTriggerAt
:
Date
|
null
,
channels
:
NotificationChannel
[]
|
null
,
)
{
if
(
!
reminder
.
nextTriggerAt
)
{
return
false
;
}
const
expectedTriggerAt
=
reminder
.
nextTriggerAt
;
const
externalChannels
=
channels
?.
filter
(
(
channel
)
=>
channel
!==
NotificationChannel
.
IN_APP
,
)
??
[];
const
notificationType
=
reminder
.
type
===
ReminderType
.
RECURRING_PAYMENT
?
NotificationType
.
RECURRING_PAYMENT_DUE
:
NotificationType
.
USER_REMINDER
;
try
{
return
await
prisma
.
$transaction
(
async
(
transaction
)
=>
{
const
current
=
await
transaction
.
reminder
.
findFirst
({
where
:
{
id
:
reminder
.
id
,
isActive
:
true
,
nextTriggerAt
:
expectedTriggerAt
,
},
select
:
{
id
:
true
},
});
if
(
!
current
)
{
return
false
;
}
if
(
channels
&&
channels
.
length
>
0
)
{
await
transaction
.
notification
.
create
({
data
:
{
userId
:
reminder
.
userId
,
type
:
notificationType
,
priority
:
reminder
.
type
===
ReminderType
.
RECURRING_PAYMENT
?
NotificationPriority
.
HIGH
:
NotificationPriority
.
NORMAL
,
title
:
reminder
.
title
,
message
:
reminder
.
message
??
'A scheduled reminder is due.'
,
channels
,
actionUrl
:
reminder
.
actionUrl
,
sourceType
:
NotificationSourceType
.
REMINDER
,
sourceId
:
reminder
.
id
,
dedupKey
:
`reminder:
${
reminder
.
id
}
:
${
expectedTriggerAt
.
toISOString
()}
`
,
data
:
{
reminderId
:
reminder
.
id
,
scheduledAt
:
expectedTriggerAt
.
toISOString
(),
},
deliveries
:
externalChannels
.
length
>
0
?
{
create
:
externalChannels
.
map
((
channel
)
=>
({
channel
})),
}
:
undefined
,
},
});
}
await
transaction
.
reminder
.
update
({
where
:
{
id
:
reminder
.
id
},
data
:
{
lastTriggeredAt
:
triggeredAt
,
nextTriggerAt
,
isActive
:
nextTriggerAt
!==
null
,
},
});
return
true
;
},
{
isolationLevel
:
Prisma
.
TransactionIsolationLevel
.
Serializable
});
}
catch
(
error
)
{
if
(
error
instanceof
Prisma
.
PrismaClientKnownRequestError
&&
(
error
.
code
===
'P2002'
||
error
.
code
===
'P2034'
)
)
{
return
false
;
}
throw
error
;
}
}
}
src/modules/reminders/reminder.route.ts
0 → 100644
View file @
132dba7b
import
{
Router
}
from
'express'
;
import
{
authMiddleware
}
from
'../../middlewares/auth.middleware'
;
import
{
validate
}
from
'../../middlewares/validate.middleware'
;
import
{
ReminderController
}
from
'./reminder.controller'
;
import
{
createReminderSchema
,
findRemindersSchema
,
reminderParamsSchema
,
updateReminderSchema
,
}
from
'./reminder.validation'
;
const
router
=
Router
();
const
controller
=
new
ReminderController
();
router
.
use
(
authMiddleware
);
router
.
get
(
'/'
,
validate
(
findRemindersSchema
,
'query'
),
controller
.
findAll
);
router
.
post
(
'/'
,
validate
(
createReminderSchema
),
controller
.
create
);
router
.
get
(
'/:id'
,
validate
(
reminderParamsSchema
,
'params'
),
controller
.
findById
);
router
.
put
(
'/:id'
,
validate
(
reminderParamsSchema
,
'params'
),
validate
(
updateReminderSchema
),
controller
.
update
,
);
router
.
delete
(
'/:id'
,
validate
(
reminderParamsSchema
,
'params'
),
controller
.
remove
);
export
default
router
;
src/modules/reminders/reminder.service.ts
0 → 100644
View file @
132dba7b
import
{
NotificationType
,
ReminderFrequency
}
from
'@prisma/client'
;
import
{
AppError
}
from
'../../common/errors/app-error'
;
import
{
ERROR_CODE
}
from
'../../common/errors/error-code'
;
import
{
NotificationService
}
from
'../notifications/notification.service'
;
import
{
CreateReminderDto
,
PersistReminderDto
,
ReminderQueryDto
,
UpdateReminderDto
,
}
from
'./reminder.dto'
;
import
{
ReminderRecord
,
ReminderRepository
}
from
'./reminder.repository'
;
export
class
ReminderService
{
private
readonly
repository
=
new
ReminderRepository
();
private
readonly
notificationService
=
new
NotificationService
();
async
findAll
(
userId
:
string
,
query
:
ReminderQueryDto
)
{
const
result
=
await
this
.
repository
.
findAll
(
userId
,
query
);
return
{
data
:
result
.
data
.
map
(
this
.
toResponse
),
meta
:
result
.
meta
};
}
async
findById
(
userId
:
string
,
id
:
string
)
{
return
this
.
toResponse
(
await
this
.
findRecord
(
userId
,
id
));
}
create
(
userId
:
string
,
data
:
CreateReminderDto
)
{
const
persistence
=
this
.
resolveCreate
(
data
);
return
this
.
repository
.
create
(
userId
,
persistence
).
then
(
this
.
toResponse
);
}
async
update
(
userId
:
string
,
id
:
string
,
data
:
UpdateReminderDto
)
{
const
current
=
await
this
.
findRecord
(
userId
,
id
);
const
persistence
=
this
.
resolveUpdate
(
current
,
data
);
return
this
.
toResponse
(
await
this
.
repository
.
update
(
id
,
persistence
));
}
async
remove
(
userId
:
string
,
id
:
string
)
{
await
this
.
findRecord
(
userId
,
id
);
return
this
.
repository
.
remove
(
id
);
}
async
processDue
(
now
:
Date
)
{
const
due
=
await
this
.
repository
.
findDue
(
now
);
for
(
const
reminder
of
due
)
{
const
nextTriggerAt
=
this
.
calculateNextTrigger
(
reminder
.
remindAt
,
reminder
.
frequency
,
reminder
.
repeatInterval
,
reminder
.
endAt
,
now
,
);
const
channels
=
await
this
.
notificationService
.
getChannelsForType
(
reminder
.
type
===
'RECURRING_PAYMENT'
?
NotificationType
.
RECURRING_PAYMENT_DUE
:
NotificationType
.
USER_REMINDER
,
reminder
.
userId
,
);
await
this
.
repository
.
triggerDue
(
reminder
,
now
,
nextTriggerAt
,
channels
);
}
return
due
.
length
;
}
private
resolveCreate
(
data
:
CreateReminderDto
):
PersistReminderDto
{
const
now
=
new
Date
();
const
repeatInterval
=
data
.
frequency
===
ReminderFrequency
.
ONCE
?
1
:
data
.
repeatInterval
;
const
nextTriggerAt
=
data
.
isActive
?
this
.
firstTrigger
(
data
.
remindAt
,
data
.
frequency
,
repeatInterval
,
data
.
endAt
??
null
,
now
)
:
null
;
return
{
type
:
data
.
type
,
title
:
data
.
title
,
message
:
data
.
message
??
null
,
remindAt
:
data
.
remindAt
,
frequency
:
data
.
frequency
,
repeatInterval
,
endAt
:
data
.
frequency
===
ReminderFrequency
.
ONCE
?
null
:
data
.
endAt
??
null
,
nextTriggerAt
,
actionUrl
:
data
.
actionUrl
??
null
,
isActive
:
data
.
isActive
,
};
}
private
resolveUpdate
(
current
:
ReminderRecord
,
data
:
UpdateReminderDto
,
):
PersistReminderDto
{
const
frequency
=
data
.
frequency
??
current
.
frequency
;
const
remindAt
=
data
.
remindAt
??
current
.
remindAt
;
const
repeatInterval
=
frequency
===
ReminderFrequency
.
ONCE
?
1
:
data
.
repeatInterval
??
current
.
repeatInterval
;
if
(
frequency
===
ReminderFrequency
.
ONCE
&&
data
.
endAt
)
{
throw
new
AppError
(
'endAt is only supported for recurring reminders'
,
422
,
ERROR_CODE
.
REMINDER_SCHEDULE_INVALID
,
);
}
const
endAt
=
frequency
===
ReminderFrequency
.
ONCE
?
null
:
data
.
endAt
!==
undefined
?
data
.
endAt
:
current
.
endAt
;
const
isActive
=
data
.
isActive
??
current
.
isActive
;
if
(
endAt
&&
endAt
<=
remindAt
)
{
throw
new
AppError
(
'endAt must be after remindAt'
,
422
,
ERROR_CODE
.
VALIDATION_ERROR
,
);
}
const
scheduleChanged
=
data
.
remindAt
!==
undefined
||
data
.
frequency
!==
undefined
||
data
.
repeatInterval
!==
undefined
||
data
.
endAt
!==
undefined
||
(
data
.
isActive
===
true
&&
!
current
.
isActive
);
const
nextTriggerAt
=
!
isActive
?
null
:
scheduleChanged
?
this
.
firstTrigger
(
remindAt
,
frequency
,
repeatInterval
,
endAt
,
new
Date
())
:
current
.
nextTriggerAt
;
return
{
type
:
data
.
type
??
current
.
type
,
title
:
data
.
title
??
current
.
title
,
message
:
data
.
message
!==
undefined
?
data
.
message
:
current
.
message
,
remindAt
,
frequency
,
repeatInterval
,
endAt
,
nextTriggerAt
,
actionUrl
:
data
.
actionUrl
!==
undefined
?
data
.
actionUrl
:
current
.
actionUrl
,
isActive
,
};
}
private
firstTrigger
(
remindAt
:
Date
,
frequency
:
ReminderFrequency
,
repeatInterval
:
number
,
endAt
:
Date
|
null
,
now
:
Date
,
)
{
if
(
frequency
===
ReminderFrequency
.
ONCE
)
{
if
(
remindAt
<=
now
)
{
throw
new
AppError
(
'A one-time reminder must be scheduled in the future'
,
422
,
ERROR_CODE
.
REMINDER_SCHEDULE_INVALID
,
);
}
return
remindAt
;
}
const
next
=
this
.
nextOccurrenceAfter
(
remindAt
,
frequency
,
repeatInterval
,
now
,
);
if
(
endAt
&&
next
>
endAt
)
{
throw
new
AppError
(
'The recurring reminder has no future occurrence before endAt'
,
422
,
ERROR_CODE
.
REMINDER_SCHEDULE_INVALID
,
);
}
return
next
;
}
private
calculateNextTrigger
(
remindAt
:
Date
,
frequency
:
ReminderFrequency
,
repeatInterval
:
number
,
endAt
:
Date
|
null
,
now
:
Date
,
):
Date
|
null
{
if
(
frequency
===
ReminderFrequency
.
ONCE
)
{
return
null
;
}
const
next
=
this
.
nextOccurrenceAfter
(
remindAt
,
frequency
,
repeatInterval
,
now
,
);
return
endAt
&&
next
>
endAt
?
null
:
next
;
}
private
nextOccurrenceAfter
(
remindAt
:
Date
,
frequency
:
ReminderFrequency
,
repeatInterval
:
number
,
after
:
Date
,
)
{
if
(
remindAt
>
after
)
{
return
remindAt
;
}
if
(
frequency
===
ReminderFrequency
.
DAILY
||
frequency
===
ReminderFrequency
.
WEEKLY
)
{
const
days
=
frequency
===
ReminderFrequency
.
DAILY
?
repeatInterval
:
repeatInterval
*
7
;
const
stepMs
=
days
*
24
*
60
*
60
*
1000
;
const
occurrence
=
Math
.
floor
(
(
after
.
getTime
()
-
remindAt
.
getTime
())
/
stepMs
,
)
+
1
;
return
new
Date
(
remindAt
.
getTime
()
+
Math
.
max
(
1
,
occurrence
)
*
stepMs
);
}
const
stepMonths
=
frequency
===
ReminderFrequency
.
MONTHLY
?
repeatInterval
:
repeatInterval
*
12
;
const
monthDifference
=
(
(
after
.
getUTCFullYear
()
-
remindAt
.
getUTCFullYear
())
*
12
+
after
.
getUTCMonth
()
-
remindAt
.
getUTCMonth
()
);
let
occurrence
=
Math
.
max
(
1
,
Math
.
floor
(
monthDifference
/
stepMonths
));
let
candidate
=
this
.
addUtcMonthsClamped
(
remindAt
,
occurrence
*
stepMonths
);
while
(
candidate
<=
after
)
{
occurrence
+=
1
;
candidate
=
this
.
addUtcMonthsClamped
(
remindAt
,
occurrence
*
stepMonths
);
}
return
candidate
;
}
private
addUtcMonthsClamped
(
date
:
Date
,
months
:
number
)
{
const
result
=
new
Date
(
date
);
const
day
=
result
.
getUTCDate
();
result
.
setUTCDate
(
1
);
result
.
setUTCMonth
(
result
.
getUTCMonth
()
+
months
);
const
lastDay
=
new
Date
(
Date
.
UTC
(
result
.
getUTCFullYear
(),
result
.
getUTCMonth
()
+
1
,
0
,
)).
getUTCDate
();
result
.
setUTCDate
(
Math
.
min
(
day
,
lastDay
));
return
result
;
}
private
async
findRecord
(
userId
:
string
,
id
:
string
)
{
const
reminder
=
await
this
.
repository
.
findById
(
userId
,
id
);
if
(
!
reminder
)
{
throw
new
AppError
(
'Reminder not found'
,
404
,
ERROR_CODE
.
NOT_FOUND
);
}
return
reminder
;
}
private
toResponse
(
reminder
:
ReminderRecord
)
{
const
{
userId
:
_userId
,
...
response
}
=
reminder
;
return
response
;
}
}
src/modules/reminders/reminder.validation.ts
0 → 100644
View file @
132dba7b
import
{
z
}
from
'zod'
;
const
reminderTypeSchema
=
z
.
enum
([
'GENERAL'
,
'RECURRING_PAYMENT'
]);
const
reminderFrequencySchema
=
z
.
enum
([
'ONCE'
,
'DAILY'
,
'WEEKLY'
,
'MONTHLY'
,
'YEARLY'
]);
const
dateSchema
=
z
.
string
()
.
datetime
({
offset
:
true
,
message
:
'Date must be a valid ISO 8601 date-time'
})
.
transform
((
value
)
=>
new
Date
(
value
));
const
nullableText
=
(
max
:
number
)
=>
z
.
union
([
z
.
string
().
trim
().
max
(
max
),
z
.
null
()])
.
transform
((
value
)
=>
value
===
''
?
null
:
value
);
const
actionUrlSchema
=
z
.
union
([
z
.
string
().
trim
().
max
(
500
).
refine
(
(
value
)
=>
value
.
startsWith
(
'/'
)
||
/^https:
\/\/
/i
.
test
(
value
),
'actionUrl must be an app-relative path or HTTPS URL'
,
),
z
.
null
(),
])
.
transform
((
value
)
=>
value
===
''
?
null
:
value
);
export
const
reminderParamsSchema
=
z
.
object
({
id
:
z
.
string
().
uuid
(
'Invalid reminder id'
),
});
export
const
findRemindersSchema
=
z
.
object
({
type
:
reminderTypeSchema
.
optional
(),
isActive
:
z
.
enum
([
'true'
,
'false'
])
.
transform
((
value
)
=>
value
===
'true'
)
.
optional
(),
dueFrom
:
dateSchema
.
optional
(),
dueTo
:
dateSchema
.
optional
(),
page
:
z
.
coerce
.
number
().
int
().
positive
().
optional
().
default
(
1
),
limit
:
z
.
coerce
.
number
().
int
().
min
(
1
).
max
(
100
).
optional
().
default
(
20
),
}).
refine
(
(
data
)
=>
!
data
.
dueFrom
||
!
data
.
dueTo
||
data
.
dueTo
>=
data
.
dueFrom
,
{
path
:
[
'dueTo'
],
message
:
'dueTo must be greater than or equal to dueFrom'
},
);
export
const
createReminderSchema
=
z
.
object
({
type
:
reminderTypeSchema
.
optional
().
default
(
'GENERAL'
),
title
:
z
.
string
().
trim
().
min
(
1
,
'Title is required'
).
max
(
160
),
message
:
nullableText
(
2000
).
optional
(),
remindAt
:
dateSchema
,
frequency
:
reminderFrequencySchema
.
optional
().
default
(
'ONCE'
),
repeatInterval
:
z
.
coerce
.
number
().
int
().
min
(
1
).
max
(
365
).
optional
().
default
(
1
),
endAt
:
dateSchema
.
nullable
().
optional
(),
actionUrl
:
actionUrlSchema
.
optional
(),
isActive
:
z
.
boolean
().
optional
().
default
(
true
),
})
.
superRefine
((
data
,
context
)
=>
{
if
(
data
.
endAt
&&
data
.
endAt
<=
data
.
remindAt
)
{
context
.
addIssue
({
code
:
z
.
ZodIssueCode
.
custom
,
path
:
[
'endAt'
],
message
:
'endAt must be after remindAt'
,
});
}
if
(
data
.
frequency
===
'ONCE'
&&
data
.
endAt
)
{
context
.
addIssue
({
code
:
z
.
ZodIssueCode
.
custom
,
path
:
[
'endAt'
],
message
:
'endAt is only supported for recurring reminders'
,
});
}
});
export
const
updateReminderSchema
=
z
.
object
({
type
:
reminderTypeSchema
.
optional
(),
title
:
z
.
string
().
trim
().
min
(
1
,
'Title cannot be empty'
).
max
(
160
).
optional
(),
message
:
nullableText
(
2000
).
optional
(),
remindAt
:
dateSchema
.
optional
(),
frequency
:
reminderFrequencySchema
.
optional
(),
repeatInterval
:
z
.
coerce
.
number
().
int
().
min
(
1
).
max
(
365
).
optional
(),
endAt
:
dateSchema
.
nullable
().
optional
(),
actionUrl
:
actionUrlSchema
.
optional
(),
isActive
:
z
.
boolean
().
optional
(),
})
.
refine
((
data
)
=>
Object
.
keys
(
data
).
length
>
0
,
{
message
:
'At least one field is required'
,
});
src/modules/transactions/transaction.service.ts
View file @
132dba7b
import
{
Prisma
,
TransactionType
}
from
'@prisma/client'
;
import
{
AppError
}
from
'../../common/errors/app-error'
;
import
{
ERROR_CODE
}
from
'../../common/errors/error-code'
;
import
{
NotificationService
}
from
'../notifications/notification.service'
;
import
{
ReceiptFileService
,
StoredReceipt
}
from
'./receipt-file.service'
;
import
{
CreateTransactionDto
,
...
...
@@ -15,6 +16,7 @@ import {
export
class
TransactionService
{
private
readonly
repository
=
new
TransactionRepository
();
private
readonly
receiptFiles
=
new
ReceiptFileService
();
private
readonly
notificationService
=
new
NotificationService
();
findAll
(
userId
:
string
,
query
:
TransactionQueryDto
)
{
return
this
.
repository
.
findAll
(
userId
,
query
);
...
...
@@ -30,8 +32,8 @@ export class TransactionService {
return
transaction
;
}
create
(
userId
:
string
,
data
:
CreateTransactionDto
)
{
return
this
.
repository
.
runSerializable
(
async
(
transaction
)
=>
{
async
create
(
userId
:
string
,
data
:
CreateTransactionDto
)
{
const
created
=
await
this
.
repository
.
runSerializable
(
async
(
transaction
)
=>
{
await
this
.
ensureValidRelations
(
userId
,
data
.
walletId
,
...
...
@@ -51,10 +53,13 @@ export class TransactionService {
return
created
;
});
await
this
.
notificationService
.
detectUnusualTransaction
(
userId
,
created
);
return
created
;
}
update
(
userId
:
string
,
id
:
string
,
data
:
UpdateTransactionDto
)
{
return
this
.
repository
.
runSerializable
(
async
(
transaction
)
=>
{
async
update
(
userId
:
string
,
id
:
string
,
data
:
UpdateTransactionDto
)
{
const
updated
=
await
this
.
repository
.
runSerializable
(
async
(
transaction
)
=>
{
const
current
=
await
this
.
repository
.
findById
(
userId
,
id
,
transaction
);
if
(
!
current
)
{
throw
new
AppError
(
'Transaction not found'
,
404
,
ERROR_CODE
.
NOT_FOUND
);
...
...
@@ -103,6 +108,9 @@ export class TransactionService {
return
this
.
repository
.
update
(
id
,
data
,
transaction
);
});
await
this
.
notificationService
.
detectUnusualTransaction
(
userId
,
updated
);
return
updated
;
}
async
delete
(
userId
:
string
,
id
:
string
)
{
...
...
src/routes/index.ts
View file @
132dba7b
...
...
@@ -7,6 +7,8 @@ import transactionRoute from '../modules/transactions/transaction.route';
import
budgetRoute
from
'../modules/budgets/budget.route'
;
import
savingGoalRoute
from
'../modules/saving-goals/saving-goal.route'
;
import
reportRoute
from
'../modules/reports/report.route'
;
import
notificationRoute
from
'../modules/notifications/notification.route'
;
import
reminderRoute
from
'../modules/reminders/reminder.route'
;
const
router
=
Router
();
...
...
@@ -22,5 +24,7 @@ router.use('/transactions', transactionRoute);
router
.
use
(
'/budgets'
,
budgetRoute
);
router
.
use
(
'/saving-goals'
,
savingGoalRoute
);
router
.
use
(
'/reports'
,
reportRoute
);
router
.
use
(
'/notifications'
,
notificationRoute
);
router
.
use
(
'/reminders'
,
reminderRoute
);
export
default
router
;
src/server.ts
View file @
132dba7b
import
'dotenv/config'
;
import
app
from
'./app'
;
import
{
envConfig
}
from
'./config/env.config'
;
import
{
notificationWorker
}
from
'./modules/notifications/notification.worker'
;
const
PORT
=
envConfig
.
port
;
app
.
listen
(
PORT
,
()
=>
{
console
.
log
(
`Server running on http://localhost:
${
PORT
}
in
${
envConfig
.
nodeEnv
}
mode`
);
notificationWorker
.
start
();
});
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