Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ startNotificationsCleanupJob();
recalculateContributionAmountCron.start();

startDebtNotificationsJob();
startBudgetSummaryJob();
startBudgetSummaryJob(); // Fixed: Now using proper CronJob instead of setTimeout
startGoalNotificationsJob();
startFinancialSuggestionsJob();
startGoalSuggestionsJob();
Expand Down
123 changes: 88 additions & 35 deletions src/core/infrastructure/cron/budget-notifications.cron.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,49 @@
import { CronJob } from 'cron';
import { PgBudgetRepository } from '@/budgets/infrastructure/adapters/budget.repository';
import { PgCategoryRepository } from '@/categories/infrastructure/adapters/category.repository';
import { PgNotificationRepository } from '@/notifications/infrastructure/adapters/notification.repository';
import { NotificationUtilsService } from '@/notifications/application/services/notification-utils.service';
import { NotificationType } from '@/notifications/domain/entities/INotification';

let isRunning = false;

// Cache to track sent notifications and prevent duplicates
const sentNotificationsCache = new Map<string, Date>();

/**
* Check if a notification was sent recently to prevent duplicates
* @param cacheKey Unique key for the notification
* @returns True if notification was sent recently
*/
function hasRecentNotification(cacheKey: string): boolean {
const lastSent = sentNotificationsCache.get(cacheKey);
if (!lastSent) return false;

const hoursSinceLastNotification = (Date.now() - lastSent.getTime()) / (1000 * 60 * 60);
return hoursSinceLastNotification < 24; // Don't send more than once per day
}

/**
* Record that a notification was sent
* @param cacheKey Unique key for the notification
*/
function recordNotificationSent(cacheKey: string): void {
sentNotificationsCache.set(cacheKey, new Date());
}

/**
* Get the name of a month in Spanish
* @param monthIndex Month index (0-11)
* @returns Month name in Spanish
*/
function getMonthName(monthIndex: number): string {
const months = [
'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio',
'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'
];
return months[monthIndex];
}

/**
* Check budgets for monthly summary and generate notifications
*/
Expand Down Expand Up @@ -40,6 +79,31 @@ const generateBudgetSummaries = async () => {

// Generate summary notifications for each user
for (const [userId, userBudgetList] of userBudgets.entries()) {
// Check if notification was already sent for this user and month
const monthKey = `${userId}_${now.getFullYear()}_${now.getMonth()}`;
const cacheKey = `BUDGET_SUMMARY_${monthKey}`;

// Check in-memory cache first
if (hasRecentNotification(cacheKey)) {
console.log(`Skipping budget summary for user ${userId} - already sent this month (cache)`);
continue;
}

// Check in database for existing notifications in the last 25 days
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const existingNotifications = await notificationRepository.findRecentByUserTypeAndTitle(
userId,
NotificationType.SUGGESTION,
`Resumen mensual de presupuestos: ${getMonthName(now.getMonth())}`,
startOfMonth
);

if (existingNotifications.length > 0) {
console.log(`Skipping budget summary for user ${userId} - already sent this month (database)`);
recordNotificationSent(cacheKey);
continue;
}

const overBudgetItems = [];
const nearLimitItems = [];
const underBudgetItems = [];
Expand Down Expand Up @@ -111,6 +175,8 @@ const generateBudgetSummaries = async () => {
true // Send email
);

// Record that notification was sent
recordNotificationSent(cacheKey);
notificationsCreated++;
}

Expand All @@ -122,46 +188,33 @@ const generateBudgetSummaries = async () => {
}
};

/**
* Cron job for monthly budget summaries
* Runs on the 28th of each month at 6 PM
* (Using 28th instead of last day to avoid date calculation issues)
*/
const budgetSummaryJob = new CronJob(
'0 18 28 * *', // At 6:00 PM on day 28 of every month
async () => {
await generateBudgetSummaries();
},
null,
false,
'America/New_York'
);

/**
* Start the budget summary notifications job
*/
export const startBudgetSummaryJob = () => {
// Run on the last day of each month at 6 PM
const scheduleCheck = () => {
const now = new Date();

// Last day of current month
const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
lastDayOfMonth.setHours(18, 0, 0, 0); // 6 PM

// If it's already past 6 PM on the last day, schedule for next month
if (now > lastDayOfMonth) {
lastDayOfMonth.setMonth(lastDayOfMonth.getMonth() + 1);
}

const delay = lastDayOfMonth.getTime() - now.getTime();
console.log(`Scheduled budget summary notifications for ${lastDayOfMonth.toISOString()}`);

setTimeout(() => {
generateBudgetSummaries().then(() => {
scheduleCheck(); // Reschedule for the next month
});
}, delay);
};

// Schedule recurring checks
scheduleCheck();
budgetSummaryJob.start();
console.log('Budget summary job started (runs at 6 PM on the 28th of each month)');
};

/**
* Get the name of a month in Spanish
* @param monthIndex Month index (0-11)
* @returns Month name in Spanish
* Stop the budget summary notifications job
*/
function getMonthName(monthIndex: number): string {
const months = [
'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio',
'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'
];
return months[monthIndex];
}
export const stopBudgetSummaryJob = () => {
budgetSummaryJob.stop();
console.log('Budget summary job stopped');
};
5 changes: 1 addition & 4 deletions src/core/infrastructure/cron/debt-notifications.cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,6 @@ export const startDebtNotificationsJob = () => {
}, delay);
};

// Run once immediately at startup
checkDebtsForNotifications();

// Schedule recurring checks
// Schedule recurring checks (don't run immediately to avoid duplicate notifications on server restart)
scheduleCheck();
};
5 changes: 1 addition & 4 deletions src/core/infrastructure/cron/goal-notifications.cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,7 @@ export const startGoalNotificationsJob = () => {
}, delay);
};

// Run once immediately at startup
checkGoalsForNotifications();

// Schedule recurring checks
// Schedule recurring checks (don't run immediately to avoid duplicate notifications on server restart)
scheduleCheck();
};

Expand Down
2 changes: 2 additions & 0 deletions src/core/infrastructure/env/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const EnvSchema = z
BREVO_API_KEY: z.string().optional(),
EMAIL_FROM_ADDRESS: z.string().email().optional(),
EMAIL_FROM_NAME: z.string().optional(),
// Frontend URL for email links
FRONTEND_URL: z.string().url().default("http://localhost:3001"),
})
.superRefine((input, ctx) => {
if (input.NODE_ENV === "production" && !input.DATABASE_AUTH_TOKEN) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { INotification } from "../entities/INotification";
import { INotification, NotificationType } from "../entities/INotification";

export interface INotificationRepository {
findAll(): Promise<INotification[]>;
Expand All @@ -11,4 +11,10 @@ export interface INotificationRepository {
markAsRead(id: number): Promise<INotification>;
markAllAsRead(userId: number): Promise<boolean>;
deleteExpired(): Promise<number>;
findRecentByUserTypeAndTitle(
userId: number,
type: NotificationType,
titlePattern: string,
afterDate: Date
): Promise<INotification[]>;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq, and, lt } from "drizzle-orm";
import { eq, and, lt, desc } from "drizzle-orm";
import DatabaseConnection from "@/core/infrastructure/database";
import { notifications } from "@/schema";
import {
Expand All @@ -21,7 +21,11 @@ export class PgNotificationRepository implements INotificationRepository {
}

async findAll(): Promise<INotification[]> {
const result = await this.db.select().from(notifications);
const result = await this.db
.select()
.from(notifications)
.orderBy(desc(notifications.created_at))
.limit(500); // Limit to last 500 notifications for admin purposes
return result.map(this.mapToEntity);
}

Expand All @@ -38,7 +42,9 @@ export class PgNotificationRepository implements INotificationRepository {
const result = await this.db
.select()
.from(notifications)
.where(eq(notifications.user_id, userId));
.where(eq(notifications.user_id, userId))
.orderBy(desc(notifications.created_at))
.limit(100); // Limit to last 100 notifications

return result.map(this.mapToEntity);
}
Expand All @@ -49,7 +55,9 @@ export class PgNotificationRepository implements INotificationRepository {
.from(notifications)
.where(
and(eq(notifications.user_id, userId), eq(notifications.read, false))
);
)
.orderBy(desc(notifications.created_at))
.limit(50); // Limit to last 50 unread notifications

return result.map(this.mapToEntity);
}
Expand Down Expand Up @@ -164,6 +172,31 @@ export class PgNotificationRepository implements INotificationRepository {
return result.map(this.mapToEntity);
}

/**
* Find notifications by user, type and title pattern created after a specific date
* Used to prevent duplicate notifications
*/
async findRecentByUserTypeAndTitle(
userId: number,
type: NotificationType,
titlePattern: string,
afterDate: Date
): Promise<INotification[]> {
const result = await this.db
.select()
.from(notifications)
.where(
and(eq(notifications.user_id, userId), eq(notifications.type, type))
);

// Filter by date and title pattern in memory
return result
.map(this.mapToEntity)
.filter(
(n) => n.createdAt >= afterDate && n.title.includes(titlePattern)
);
}

private mapToEntity(raw: any): INotification {
return {
id: raw.id,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import env from '@/env';

export const baseTemplate = (title: string, subtitle: string | null, message: string, type: string) => {
const typeColor = {
GOAL: '#4CAF50',
Expand All @@ -8,6 +10,7 @@ export const baseTemplate = (title: string, subtitle: string | null, message: st
};

const color = typeColor[type as keyof typeof typeColor] || '#757575';
const frontendUrl = env.FRONTEND_URL || 'http://localhost:3001';

return `
<!DOCTYPE html>
Expand Down Expand Up @@ -74,7 +77,7 @@ export const baseTemplate = (title: string, subtitle: string | null, message: st
</div>
<div class="content">
<p>${message.replace(/\n/g, '<br>')}</p>
<a href="#" class="button">Ver en la aplicación</a>
<a href="${frontendUrl}/management" class="button">Ver en la aplicación</a>
</div>
<div class="footer">
<p>Fopymes - Tu aplicación de finanzas personales</p>
Expand Down
Loading