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
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';

export class AddGoalMilestonesAndPreferences1774445030 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Add milestonesSent JSONB to savings_goals
await queryRunner.query(
`ALTER TABLE "savings_goals" ADD COLUMN IF NOT EXISTS "milestonesSent" jsonb DEFAULT '{}'::jsonb`,
);

// Add milestoneNotifications boolean to notification_preferences
await queryRunner.query(
`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "milestoneNotifications" boolean DEFAULT true`,
);

// Create goal_milestone_events table for analytics/tracking
await queryRunner.createTable(
new Table({
name: 'goal_milestone_events',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'gen_random_uuid()',
},
{
name: 'userId',
type: 'uuid',
isNullable: false,
},
{
name: 'goalId',
type: 'uuid',
isNullable: false,
},
{
name: 'percentage',
type: 'integer',
isNullable: false,
},
{
name: 'metadata',
type: 'jsonb',
isNullable: true,
},
{
name: 'occurredAt',
type: 'timestamp',
default: 'now()',
isNullable: false,
},
],
}),
true,
);

// Add new enum values for notifications.type if they don't exist
// Note: altering enum types can fail on older PG versions; we ignore errors
try {
await queryRunner.query(
`ALTER TYPE "notifications_type_enum" ADD VALUE 'GOAL_MILESTONE'`,
);
} catch (e) {
// ignore
}

try {
await queryRunner.query(
`ALTER TYPE "notifications_type_enum" ADD VALUE 'GOAL_COMPLETED'`,
);
} catch (e) {
// ignore
}
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Remove added columns and table. Note: we do not remove enum values as it's non-trivial.
await queryRunner.query(
`ALTER TABLE "savings_goals" DROP COLUMN IF EXISTS "milestonesSent"`,
);

await queryRunner.query(
`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "milestoneNotifications"`,
);

await queryRunner.query(`DROP TABLE IF EXISTS "goal_milestone_events"`);
}
}
28 changes: 28 additions & 0 deletions backend/src/modules/mail/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,32 @@ export class MailService {
);
}
}

async sendGoalMilestoneEmail(
userEmail: string,
name: string,
goalName: string,
percentage: number,
): Promise<void> {
try {
await this.mailerService.sendMail({
to: userEmail,
subject: `Congrats — ${percentage}% of your goal achieved!`,
template: './goal-milestone',
context: {
name: name || 'User',
goalName,
percentage,
},
});
this.logger.log(
`Goal milestone email (${percentage}%) sent to ${userEmail}`,
);
} catch (error) {
this.logger.error(
`Failed to send goal milestone email to ${userEmail}`,
error,
);
}
}
}
11 changes: 11 additions & 0 deletions backend/src/modules/mail/templates/goal-milestone.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<h1>Congratulations {{name}}!</h1>

{{#if percentage}}
<p>You've reached <strong>{{percentage}}%</strong> of your goal <em>{{goalName}}</em> — awesome progress!</p>
{{else}}
<p>You're making progress toward <em>{{goalName}}</em> — keep going!</p>
{{/if}}

<p>Head to your Nestera dashboard to see details and keep up the momentum.</p>

<p>— The Nestera Team</p>
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,9 @@ export class UpdateNotificationPreferenceDto {
@IsOptional()
@IsBoolean()
yieldNotifications?: boolean;

@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
milestoneNotifications?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export class NotificationPreference {
@Column({ type: 'boolean', default: true })
yieldNotifications: boolean;

@Column({ type: 'boolean', default: true })
milestoneNotifications: boolean;

@CreateDateColumn()
createdAt: Date;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export enum NotificationType {
CLAIM_REJECTED = 'CLAIM_REJECTED',
YIELD_EARNED = 'YIELD_EARNED',
DEPOSIT_RECEIVED = 'DEPOSIT_RECEIVED',
GOAL_MILESTONE = 'GOAL_MILESTONE',
GOAL_COMPLETED = 'GOAL_COMPLETED',
}

@Entity('notifications')
Expand Down
115 changes: 115 additions & 0 deletions backend/src/modules/notifications/milestone-scheduler.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
SavingsGoal,
SavingsGoalStatus,
} from '../savings/entities/savings-goal.entity';
import { SavingsService } from '../savings/savings.service';

@Injectable()
export class MilestoneSchedulerService {
private readonly logger = new Logger(MilestoneSchedulerService.name);

// Milestones to track (percentages)
private readonly MILESTONES = [25, 50, 75, 100];

constructor(
@InjectRepository(SavingsGoal)
private readonly goalRepository: Repository<SavingsGoal>,
private readonly savingsService: SavingsService,
private readonly eventEmitter: EventEmitter2,
) {}

// Run daily at midnight UTC
@Cron('0 0 0 * * *')
async handleDailyMilestones() {
this.logger.log('Running daily milestone scheduler');

try {
const goals = await this.goalRepository.find({
where: { status: SavingsGoalStatus.IN_PROGRESS },
});

if (!goals.length) {
this.logger.log('No active goals found');
return;
}

// Group by userId to minimize repeated work
const byUser = new Map<string, SavingsGoal[]>();
for (const g of goals) {
const arr = byUser.get(g.userId) || [];
arr.push(g);
byUser.set(g.userId, arr);
}

for (const [userId, userGoals] of byUser.entries()) {
// Use existing savingsService to compute goal progress for this user
const progresses = await this.savingsService.findMyGoals(userId);

for (const progress of progresses) {
const goalEntity = await this.goalRepository.findOne({
where: { id: progress.id },
});

if (!goalEntity) continue;

// Ensure milestonesSent is an object
const sent = goalEntity.milestonesSent || {};

for (const milestone of this.MILESTONES) {
if (
progress.percentageComplete >= milestone &&
!sent[String(milestone)]
) {
// Mark as sent
sent[String(milestone)] = new Date().toISOString();
goalEntity.milestonesSent = sent;
await this.goalRepository.save(goalEntity);

// Emit event for notifications and analytics
this.eventEmitter.emit('goal.milestone', {
userId,
goalId: progress.id,
percentage: milestone,
goalName: progress.goalName,
metadata: {
currentBalance: progress.currentBalance,
targetAmount: progress.targetAmount,
},
});

// Also write a lightweight analytics row for tracking
try {
await this.goalRepository.manager.insert(
'goal_milestone_events',
{
userId,
goalId: progress.id,
percentage: milestone,
metadata: {
currentBalance: progress.currentBalance,
targetAmount: progress.targetAmount,
},
},
);
} catch (e) {
this.logger.warn(
'Failed to insert milestone analytics row',
e as any,
);
}
}
}
}
}

this.logger.log('Daily milestone scheduler completed');
} catch (error) {
this.logger.error('Error running milestone scheduler', error as any);
}
}
}
5 changes: 4 additions & 1 deletion backend/src/modules/notifications/notifications.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import { Notification } from './entities/notification.entity';
import { NotificationPreference } from './entities/notification-preference.entity';
import { MailModule } from '../mail/mail.module';
import { User } from '../user/entities/user.entity';
import { MilestoneSchedulerService } from './milestone-scheduler.service';
import { SavingsModule } from '../savings/savings.module';

@Module({
imports: [
TypeOrmModule.forFeature([Notification, NotificationPreference, User]),
MailModule,
SavingsModule,
],
controllers: [NotificationsController],
providers: [NotificationsService],
providers: [NotificationsService, MilestoneSchedulerService],
exports: [NotificationsService],
})
export class NotificationsModule {}
85 changes: 85 additions & 0 deletions backend/src/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,91 @@ export class NotificationsService {
}
}

/**
* Handle goal milestone events emitted by the scheduler.
* Payload: { userId, goalId, percentage, goalName, metadata? }
*/
@OnEvent('goal.milestone')
async handleGoalMilestone(event: {
userId: string;
goalId: string;
percentage: number;
goalName: string;
metadata?: Record<string, any>;
}) {
this.logger.log(
`Processing goal.milestone event for user ${event.userId} (goal ${event.goalId})`,
);

try {
const user = await this.userRepository.findOne({
where: { id: event.userId },
});

if (!user) {
this.logger.warn(
`User ${event.userId} not found for goal milestone notification`,
);
return;
}

const preferences = await this.getOrCreatePreferences(event.userId);

const title =
event.percentage === 100
? `Goal complete: ${event.goalName}`
: `Milestone reached: ${event.percentage}%`;

const message =
event.percentage === 100
? `Amazing — you've reached your goal "${event.goalName}"!`
: `You're ${event.percentage}% of the way to "${event.goalName}" — keep it up!`;

// Create in-app notification if enabled
if (
preferences.inAppNotifications &&
preferences.milestoneNotifications
) {
await this.createNotification({
userId: event.userId,
type:
event.percentage === 100
? NotificationType.GOAL_COMPLETED
: NotificationType.GOAL_MILESTONE,
title,
message,
metadata: {
goalId: event.goalId,
percentage: event.percentage,
...event.metadata,
},
});
}

// Send email if enabled
if (
preferences.emailNotifications &&
preferences.milestoneNotifications
) {
await this.mailService.sendGoalMilestoneEmail(
user.email,
user.name || 'User',
event.goalName,
event.percentage,
);
}

this.logger.log(
`Goal milestone notification processed for user ${event.userId} (goal ${event.goalId})`,
);
} catch (error) {
this.logger.error(
`Error processing goal.milestone event for user ${event.userId}`,
error,
);
}
}

/**
* Create a notification in the database
*/
Expand Down
Loading
Loading