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ê
- Financial Reports là module chỉ đọc, tổng hợp trực tiếp Wallet, Transaction, Budget và
Saving Goal. Báo cáo dùng khoảng thời gian `[from, to)`, hỗ trợ preset ngày/tuần/tháng/năm
hoặc custom tối đa 1830 ngày, bucket theo offset múi giờ và luôn tách số tiền theo currency.
- Notification dùng inbox theo ownership và database-backed delivery outbox với khóa chống
trùng theo sự kiện. Kênh mặc định là `IN_APP`; email dùng SMTP hiện có, còn Zalo/push giữ
trạng thái delivery riêng để bổ sung provider adapter sau.
- Reminder hỗ trợ `ONCE`, `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`, có khoảng lặp và ngày kết
thúc. Worker nền trong process xử lý reminder, cảnh báo ngân sách/mục tiêu và retry delivery;
có thể tắt hoặc chỉnh chu kỳ bằng các biến `NOTIFICATION_*`.
- Giao dịch chi bất thường được cảnh báo khi có ít nhất 5 giao dịch lịch sử 90 ngày trong cùng
ví và số tiền mới đạt ít nhất 3 lần trung bình; đây là heuristic có thể thay bằng AI sau.
## Trạng thái đã biết
......@@ -51,6 +59,8 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
lifecycle, constraint và index phục vụ theo dõi tiến độ;
`20260803120000_add_budget_currency` thêm currency và index thời gian theo currency
cho Budget, backfill dữ liệu hiện có bằng `VND`.
`20260804120000_add_notification_reminder_system` thêm notification inbox, preferences,
delivery outbox và user reminders cùng các enum/index phục vụ worker nền.
Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth đã
được commit trước đó.
......
......@@ -62,6 +62,8 @@ Express router
`src/modules/`.
- Financial Reports tổng hợp dữ liệu hiện có theo khoảng thời gian và currency,
không lưu snapshot báo cáo riêng trong database.
- Notification cung cấp inbox, trạng thái đã đọc, cấu hình kênh và outbox giao nhận;
Reminder hỗ trợ lịch một lần hoặc lặp lại và được xử lý bởi worker nền.
- Email verification, password reset, cảnh báo thiết bị và quản lý session nằm
trong module auth.
......
......@@ -30,3 +30,7 @@ ALLOWED_ORIGINS=http://localhost:7777,http://localhost:3000,http://localhost:517
RECEIPT_UPLOAD_DIR=storage/receipts
RECEIPT_MAX_FILE_SIZE_MB=5
NOTIFICATION_WORKER_ENABLED=true
NOTIFICATION_WORKER_INTERVAL_MS=60000
NOTIFICATION_FINANCIAL_SCAN_INTERVAL_MS=300000
-- 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 {
COMPLETED
}
enum NotificationType {
BUDGET_NEAR_LIMIT
BUDGET_EXCEEDED
SAVING_GOAL_NEAR_TARGET
SAVING_GOAL_ACHIEVED
SAVING_GOAL_DUE_SOON
RECURRING_PAYMENT_DUE
UNUSUAL_TRANSACTION
USER_REMINDER
SYSTEM
}
enum NotificationPriority {
LOW
NORMAL
HIGH
CRITICAL
}
enum NotificationChannel {
IN_APP
EMAIL
ZALO
PUSH
}
enum NotificationDeliveryStatus {
PENDING
PROCESSING
SENT
FAILED
SKIPPED
}
enum NotificationSourceType {
BUDGET
SAVING_GOAL
TRANSACTION
REMINDER
SYSTEM
}
enum ReminderType {
GENERAL
RECURRING_PAYMENT
}
enum ReminderFrequency {
ONCE
DAILY
WEEKLY
MONTHLY
YEARLY
}
model User {
id String @id @default(uuid()) @db.Uuid
email String? @unique
......@@ -56,6 +111,9 @@ model User {
verificationTokens VerificationToken[]
passwordResetTokens PasswordResetToken[]
devices UserDevice[]
notifications Notification[]
notificationSetting NotificationSetting?
reminders Reminder[]
@@index([roleId])
@@map("users")
......@@ -282,3 +340,91 @@ model UserDevice {
@@unique([userId, deviceHash])
@@map("user_devices")
}
model Notification {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
type NotificationType
priority NotificationPriority @default(NORMAL)
title String @db.VarChar(160)
message String @db.Text
channels NotificationChannel[] @default([IN_APP])
data Json?
actionUrl String? @map("action_url") @db.VarChar(500)
sourceType NotificationSourceType? @map("source_type")
sourceId String? @map("source_id") @db.Uuid
dedupKey String @map("dedup_key") @db.VarChar(255)
readAt DateTime? @map("read_at")
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deliveries NotificationDelivery[]
@@unique([userId, dedupKey])
@@index([userId, createdAt])
@@index([userId, readAt, createdAt])
@@index([sourceType, sourceId])
@@map("notifications")
}
model NotificationDelivery {
id String @id @default(uuid()) @db.Uuid
notificationId String @map("notification_id") @db.Uuid
channel NotificationChannel
status NotificationDeliveryStatus @default(PENDING)
attemptCount Int @default(0) @map("attempt_count")
nextAttemptAt DateTime @default(now()) @map("next_attempt_at")
sentAt DateTime? @map("sent_at")
failureReason String? @map("failure_reason") @db.VarChar(500)
providerMessageId String? @map("provider_message_id") @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
@@unique([notificationId, channel])
@@index([status, nextAttemptAt])
@@map("notification_deliveries")
}
model NotificationSetting {
id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid
channels NotificationChannel[] @default([IN_APP])
budgetAlertsEnabled Boolean @default(true) @map("budget_alerts_enabled")
savingGoalAlertsEnabled Boolean @default(true) @map("saving_goal_alerts_enabled")
reminderAlertsEnabled Boolean @default(true) @map("reminder_alerts_enabled")
unusualTxnAlertsEnabled Boolean @default(true) @map("unusual_txn_alerts_enabled")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("notification_settings")
}
model Reminder {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
type ReminderType @default(GENERAL)
title String @db.VarChar(160)
message String? @db.Text
remindAt DateTime @map("remind_at")
frequency ReminderFrequency @default(ONCE)
repeatInterval Int @default(1) @map("repeat_interval")
endAt DateTime? @map("end_at")
nextTriggerAt DateTime? @map("next_trigger_at")
lastTriggeredAt DateTime? @map("last_triggered_at")
actionUrl String? @map("action_url") @db.VarChar(500)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, isActive, nextTriggerAt])
@@index([nextTriggerAt, isActive])
@@map("reminders")
}
......@@ -23,6 +23,7 @@ export const ERROR_CODE = {
SAVING_GOAL_PAUSED: 'SAVING_GOAL_PAUSED',
SAVING_GOAL_COMPLETED: 'SAVING_GOAL_COMPLETED',
SAVING_GOAL_CURRENCY_LOCKED: 'SAVING_GOAL_CURRENCY_LOCKED',
REMINDER_SCHEDULE_INVALID: 'REMINDER_SCHEDULE_INVALID',
FILE_TYPE_UNSUPPORTED: 'FILE_TYPE_UNSUPPORTED',
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
RECEIPT_NOT_FOUND: 'RECEIPT_NOT_FOUND',
......
......@@ -144,4 +144,43 @@ export class MailService {
throw error;
}
}
async sendNotificationEmail(
email: string,
notification: {
title: string;
message: string;
actionUrl: string | null;
},
fullName?: string | null,
) {
const escapeHtml = (value: string) => value
.replace(/&/g, '&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 = {
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';
export const mailConfig = {
appUrl,
host: process.env.MAIL_HOST || 'smtp.gmail.com',
port: parseInt(process.env.MAIL_PORT || '587', 10),
secure: process.env.MAIL_SECURE === 'true', // true for 465, false for 587
......
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 { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { NotificationService } from '../notifications/notification.service';
import { ReceiptFileService, StoredReceipt } from './receipt-file.service';
import {
CreateTransactionDto,
......@@ -15,6 +16,7 @@ import {
export class TransactionService {
private readonly repository = new TransactionRepository();
private readonly receiptFiles = new ReceiptFileService();
private readonly notificationService = new NotificationService();
findAll(userId: string, query: TransactionQueryDto) {
return this.repository.findAll(userId, query);
......@@ -30,8 +32,8 @@ export class TransactionService {
return transaction;
}
create(userId: string, data: CreateTransactionDto) {
return this.repository.runSerializable(async (transaction) => {
async create(userId: string, data: CreateTransactionDto) {
const created = await this.repository.runSerializable(async (transaction) => {
await this.ensureValidRelations(
userId,
data.walletId,
......@@ -51,10 +53,13 @@ export class TransactionService {
return created;
});
await this.notificationService.detectUnusualTransaction(userId, created);
return created;
}
update(userId: string, id: string, data: UpdateTransactionDto) {
return this.repository.runSerializable(async (transaction) => {
async update(userId: string, id: string, data: UpdateTransactionDto) {
const updated = await this.repository.runSerializable(async (transaction) => {
const current = await this.repository.findById(userId, id, transaction);
if (!current) {
throw new AppError('Transaction not found', 404, ERROR_CODE.NOT_FOUND);
......@@ -103,6 +108,9 @@ export class TransactionService {
return this.repository.update(id, data, transaction);
});
await this.notificationService.detectUnusualTransaction(userId, updated);
return updated;
}
async delete(userId: string, id: string) {
......
......@@ -7,6 +7,8 @@ import transactionRoute from '../modules/transactions/transaction.route';
import budgetRoute from '../modules/budgets/budget.route';
import savingGoalRoute from '../modules/saving-goals/saving-goal.route';
import reportRoute from '../modules/reports/report.route';
import notificationRoute from '../modules/notifications/notification.route';
import reminderRoute from '../modules/reminders/reminder.route';
const router = Router();
......@@ -22,5 +24,7 @@ router.use('/transactions', transactionRoute);
router.use('/budgets', budgetRoute);
router.use('/saving-goals', savingGoalRoute);
router.use('/reports', reportRoute);
router.use('/notifications', notificationRoute);
router.use('/reminders', reminderRoute);
export default router;
import 'dotenv/config';
import app from './app';
import { envConfig } from './config/env.config';
import { notificationWorker } from './modules/notifications/notification.worker';
const PORT = envConfig.port;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT} in ${envConfig.nodeEnv} mode`);
notificationWorker.start();
});
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