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
Hide 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ê
...
@@ -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à
-
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
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.
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
## 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ê
...
@@ -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 độ;
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
`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`
.
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 đã
Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth đã
được commit trước đó.
được commit trước đó.
...
...
.agents/project.md
View file @
132dba7b
...
@@ -62,6 +62,8 @@ Express router
...
@@ -62,6 +62,8 @@ Express router
`src/modules/`
.
`src/modules/`
.
-
Financial Reports tổng hợp dữ liệu hiện có theo khoảng thời gian và currency,
-
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.
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
-
Email verification, password reset, cảnh báo thiết bị và quản lý session nằm
trong module auth.
trong module auth.
...
...
.env.example
View file @
132dba7b
...
@@ -30,3 +30,7 @@ ALLOWED_ORIGINS=http://localhost:7777,http://localhost:3000,http://localhost:517
...
@@ -30,3 +30,7 @@ ALLOWED_ORIGINS=http://localhost:7777,http://localhost:3000,http://localhost:517
RECEIPT_UPLOAD_DIR=storage/receipts
RECEIPT_UPLOAD_DIR=storage/receipts
RECEIPT_MAX_FILE_SIZE_MB=5
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 {
...
@@ -30,6 +30,61 @@ enum SavingGoalStatus {
COMPLETED
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
{
model
User
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
email
String
?
@
unique
email
String
?
@
unique
...
@@ -56,6 +111,9 @@ model User {
...
@@ -56,6 +111,9 @@ model User {
verificationTokens
VerificationToken
[]
verificationTokens
VerificationToken
[]
passwordResetTokens
PasswordResetToken
[]
passwordResetTokens
PasswordResetToken
[]
devices
UserDevice
[]
devices
UserDevice
[]
notifications
Notification
[]
notificationSetting
NotificationSetting
?
reminders
Reminder
[]
@@
index
([
roleId
])
@@
index
([
roleId
])
@@
map
(
"users"
)
@@
map
(
"users"
)
...
@@ -282,3 +340,91 @@ model UserDevice {
...
@@ -282,3 +340,91 @@ model UserDevice {
@@
unique
([
userId
,
deviceHash
])
@@
unique
([
userId
,
deviceHash
])
@@
map
(
"user_devices"
)
@@
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 = {
...
@@ -23,6 +23,7 @@ export const ERROR_CODE = {
SAVING_GOAL_PAUSED
:
'SAVING_GOAL_PAUSED'
,
SAVING_GOAL_PAUSED
:
'SAVING_GOAL_PAUSED'
,
SAVING_GOAL_COMPLETED
:
'SAVING_GOAL_COMPLETED'
,
SAVING_GOAL_COMPLETED
:
'SAVING_GOAL_COMPLETED'
,
SAVING_GOAL_CURRENCY_LOCKED
:
'SAVING_GOAL_CURRENCY_LOCKED'
,
SAVING_GOAL_CURRENCY_LOCKED
:
'SAVING_GOAL_CURRENCY_LOCKED'
,
REMINDER_SCHEDULE_INVALID
:
'REMINDER_SCHEDULE_INVALID'
,
FILE_TYPE_UNSUPPORTED
:
'FILE_TYPE_UNSUPPORTED'
,
FILE_TYPE_UNSUPPORTED
:
'FILE_TYPE_UNSUPPORTED'
,
FILE_TOO_LARGE
:
'FILE_TOO_LARGE'
,
FILE_TOO_LARGE
:
'FILE_TOO_LARGE'
,
RECEIPT_NOT_FOUND
:
'RECEIPT_NOT_FOUND'
,
RECEIPT_NOT_FOUND
:
'RECEIPT_NOT_FOUND'
,
...
...
src/common/services/mail.service.ts
View file @
132dba7b
...
@@ -144,4 +144,43 @@ export class MailService {
...
@@ -144,4 +144,43 @@ export class MailService {
throw
error
;
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 = {
...
@@ -41,4 +41,22 @@ export const envConfig = {
return
this
.
maxFileSizeMb
*
1024
*
1024
;
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'
;
const
appUrl
=
process
.
env
.
APP_URL
||
'http://localhost:7777'
;
export
const
mailConfig
=
{
export
const
mailConfig
=
{
appUrl
,
host
:
process
.
env
.
MAIL_HOST
||
'smtp.gmail.com'
,
host
:
process
.
env
.
MAIL_HOST
||
'smtp.gmail.com'
,
port
:
parseInt
(
process
.
env
.
MAIL_PORT
||
'587'
,
10
),
port
:
parseInt
(
process
.
env
.
MAIL_PORT
||
'587'
,
10
),
secure
:
process
.
env
.
MAIL_SECURE
===
'true'
,
// true for 465, false for 587
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
{
Prisma
,
TransactionType
}
from
'@prisma/client'
;
import
{
AppError
}
from
'../../common/errors/app-error'
;
import
{
AppError
}
from
'../../common/errors/app-error'
;
import
{
ERROR_CODE
}
from
'../../common/errors/error-code'
;
import
{
ERROR_CODE
}
from
'../../common/errors/error-code'
;
import
{
NotificationService
}
from
'../notifications/notification.service'
;
import
{
ReceiptFileService
,
StoredReceipt
}
from
'./receipt-file.service'
;
import
{
ReceiptFileService
,
StoredReceipt
}
from
'./receipt-file.service'
;
import
{
import
{
CreateTransactionDto
,
CreateTransactionDto
,
...
@@ -15,6 +16,7 @@ import {
...
@@ -15,6 +16,7 @@ import {
export
class
TransactionService
{
export
class
TransactionService
{
private
readonly
repository
=
new
TransactionRepository
();
private
readonly
repository
=
new
TransactionRepository
();
private
readonly
receiptFiles
=
new
ReceiptFileService
();
private
readonly
receiptFiles
=
new
ReceiptFileService
();
private
readonly
notificationService
=
new
NotificationService
();
findAll
(
userId
:
string
,
query
:
TransactionQueryDto
)
{
findAll
(
userId
:
string
,
query
:
TransactionQueryDto
)
{
return
this
.
repository
.
findAll
(
userId
,
query
);
return
this
.
repository
.
findAll
(
userId
,
query
);
...
@@ -30,8 +32,8 @@ export class TransactionService {
...
@@ -30,8 +32,8 @@ export class TransactionService {
return
transaction
;
return
transaction
;
}
}
create
(
userId
:
string
,
data
:
CreateTransactionDto
)
{
async
create
(
userId
:
string
,
data
:
CreateTransactionDto
)
{
return
this
.
repository
.
runSerializable
(
async
(
transaction
)
=>
{
const
created
=
await
this
.
repository
.
runSerializable
(
async
(
transaction
)
=>
{
await
this
.
ensureValidRelations
(
await
this
.
ensureValidRelations
(
userId
,
userId
,
data
.
walletId
,
data
.
walletId
,
...
@@ -51,10 +53,13 @@ export class TransactionService {
...
@@ -51,10 +53,13 @@ export class TransactionService {
return
created
;
return
created
;
});
});
await
this
.
notificationService
.
detectUnusualTransaction
(
userId
,
created
);
return
created
;
}
}
update
(
userId
:
string
,
id
:
string
,
data
:
UpdateTransactionDto
)
{
async
update
(
userId
:
string
,
id
:
string
,
data
:
UpdateTransactionDto
)
{
return
this
.
repository
.
runSerializable
(
async
(
transaction
)
=>
{
const
updated
=
await
this
.
repository
.
runSerializable
(
async
(
transaction
)
=>
{
const
current
=
await
this
.
repository
.
findById
(
userId
,
id
,
transaction
);
const
current
=
await
this
.
repository
.
findById
(
userId
,
id
,
transaction
);
if
(
!
current
)
{
if
(
!
current
)
{
throw
new
AppError
(
'Transaction not found'
,
404
,
ERROR_CODE
.
NOT_FOUND
);
throw
new
AppError
(
'Transaction not found'
,
404
,
ERROR_CODE
.
NOT_FOUND
);
...
@@ -103,6 +108,9 @@ export class TransactionService {
...
@@ -103,6 +108,9 @@ export class TransactionService {
return
this
.
repository
.
update
(
id
,
data
,
transaction
);
return
this
.
repository
.
update
(
id
,
data
,
transaction
);
});
});
await
this
.
notificationService
.
detectUnusualTransaction
(
userId
,
updated
);
return
updated
;
}
}
async
delete
(
userId
:
string
,
id
:
string
)
{
async
delete
(
userId
:
string
,
id
:
string
)
{
...
...
src/routes/index.ts
View file @
132dba7b
...
@@ -7,6 +7,8 @@ import transactionRoute from '../modules/transactions/transaction.route';
...
@@ -7,6 +7,8 @@ import transactionRoute from '../modules/transactions/transaction.route';
import
budgetRoute
from
'../modules/budgets/budget.route'
;
import
budgetRoute
from
'../modules/budgets/budget.route'
;
import
savingGoalRoute
from
'../modules/saving-goals/saving-goal.route'
;
import
savingGoalRoute
from
'../modules/saving-goals/saving-goal.route'
;
import
reportRoute
from
'../modules/reports/report.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
();
const
router
=
Router
();
...
@@ -22,5 +24,7 @@ router.use('/transactions', transactionRoute);
...
@@ -22,5 +24,7 @@ router.use('/transactions', transactionRoute);
router
.
use
(
'/budgets'
,
budgetRoute
);
router
.
use
(
'/budgets'
,
budgetRoute
);
router
.
use
(
'/saving-goals'
,
savingGoalRoute
);
router
.
use
(
'/saving-goals'
,
savingGoalRoute
);
router
.
use
(
'/reports'
,
reportRoute
);
router
.
use
(
'/reports'
,
reportRoute
);
router
.
use
(
'/notifications'
,
notificationRoute
);
router
.
use
(
'/reminders'
,
reminderRoute
);
export
default
router
;
export
default
router
;
src/server.ts
View file @
132dba7b
import
'dotenv/config'
;
import
'dotenv/config'
;
import
app
from
'./app'
;
import
app
from
'./app'
;
import
{
envConfig
}
from
'./config/env.config'
;
import
{
envConfig
}
from
'./config/env.config'
;
import
{
notificationWorker
}
from
'./modules/notifications/notification.worker'
;
const
PORT
=
envConfig
.
port
;
const
PORT
=
envConfig
.
port
;
app
.
listen
(
PORT
,
()
=>
{
app
.
listen
(
PORT
,
()
=>
{
console
.
log
(
`Server running on http://localhost:
${
PORT
}
in
${
envConfig
.
nodeEnv
}
mode`
);
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