Commit 132dba7b authored by ThinhNC's avatar ThinhNC

feat(notifications): add notification and reminder system

parent 1b9a470d
...@@ -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 đó.
......
...@@ -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.
......
...@@ -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
-- 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;
...@@ -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")
}
...@@ -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',
......
...@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
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>
`,
});
}
} }
...@@ -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;
})(),
},
}; };
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
......
This diff is collapsed.
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';
}
}
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);
}
};
}
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,
};
This diff is collapsed.
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;
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;
}
}
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',
});
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();
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);
}
};
}
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;
}
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;
}
}
}
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;
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;
}
}
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',
});
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) {
......
...@@ -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;
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();
}); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment