From 34600194107329b34b07ee9ae9749e25770d1047 Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Sun, 29 Mar 2026 12:05:25 +0100 Subject: [PATCH 1/3] Commit before rebase --- ...4445030-AddGoalMilestonesAndPreferences.ts | 88 ++++++++++++++ backend/src/modules/mail/mail.service.ts | 28 +++++ .../modules/mail/templates/goal-milestone.hbs | 11 ++ .../dto/update-notification-preference.dto.ts | 5 + .../notification-preference.entity.ts | 3 + .../entities/notification.entity.ts | 2 + .../milestone-scheduler.service.ts | 115 ++++++++++++++++++ .../notifications/notifications.module.ts | 5 +- .../notifications/notifications.service.ts | 85 +++++++++++++ .../savings/entities/savings-goal.entity.ts | 9 ++ 10 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 backend/src/migrations/1774445030-AddGoalMilestonesAndPreferences.ts create mode 100644 backend/src/modules/mail/templates/goal-milestone.hbs create mode 100644 backend/src/modules/notifications/milestone-scheduler.service.ts diff --git a/backend/src/migrations/1774445030-AddGoalMilestonesAndPreferences.ts b/backend/src/migrations/1774445030-AddGoalMilestonesAndPreferences.ts new file mode 100644 index 000000000..d645dee68 --- /dev/null +++ b/backend/src/migrations/1774445030-AddGoalMilestonesAndPreferences.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +export class AddGoalMilestonesAndPreferences1774445030 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 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 { + // 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"`); + } +} diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index d34b95b46..bf991b89f 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -74,4 +74,32 @@ export class MailService { ); } } + + async sendGoalMilestoneEmail( + userEmail: string, + name: string, + goalName: string, + percentage: number, + ): Promise { + 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, + ); + } + } } diff --git a/backend/src/modules/mail/templates/goal-milestone.hbs b/backend/src/modules/mail/templates/goal-milestone.hbs new file mode 100644 index 000000000..2e98f208a --- /dev/null +++ b/backend/src/modules/mail/templates/goal-milestone.hbs @@ -0,0 +1,11 @@ +

Congratulations {{name}}!

+ +{{#if percentage}} +

You've reached {{percentage}}% of your goal {{goalName}} — awesome progress!

+{{else}} +

You're making progress toward {{goalName}} — keep going!

+{{/if}} + +

Head to your Nestera dashboard to see details and keep up the momentum.

+ +

— The Nestera Team

diff --git a/backend/src/modules/notifications/dto/update-notification-preference.dto.ts b/backend/src/modules/notifications/dto/update-notification-preference.dto.ts index 800cfcfa8..34750823f 100644 --- a/backend/src/modules/notifications/dto/update-notification-preference.dto.ts +++ b/backend/src/modules/notifications/dto/update-notification-preference.dto.ts @@ -26,4 +26,9 @@ export class UpdateNotificationPreferenceDto { @IsOptional() @IsBoolean() yieldNotifications?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + milestoneNotifications?: boolean; } diff --git a/backend/src/modules/notifications/entities/notification-preference.entity.ts b/backend/src/modules/notifications/entities/notification-preference.entity.ts index 4e3892353..7442a1496 100644 --- a/backend/src/modules/notifications/entities/notification-preference.entity.ts +++ b/backend/src/modules/notifications/entities/notification-preference.entity.ts @@ -31,6 +31,9 @@ export class NotificationPreference { @Column({ type: 'boolean', default: true }) yieldNotifications: boolean; + @Column({ type: 'boolean', default: true }) + milestoneNotifications: boolean; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/modules/notifications/entities/notification.entity.ts b/backend/src/modules/notifications/entities/notification.entity.ts index ab5bae329..b43dbedd0 100644 --- a/backend/src/modules/notifications/entities/notification.entity.ts +++ b/backend/src/modules/notifications/entities/notification.entity.ts @@ -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') diff --git a/backend/src/modules/notifications/milestone-scheduler.service.ts b/backend/src/modules/notifications/milestone-scheduler.service.ts new file mode 100644 index 000000000..cc54cbe22 --- /dev/null +++ b/backend/src/modules/notifications/milestone-scheduler.service.ts @@ -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, + 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(); + 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); + } + } +} diff --git a/backend/src/modules/notifications/notifications.module.ts b/backend/src/modules/notifications/notifications.module.ts index 09c893f2c..0cabc1929 100644 --- a/backend/src/modules/notifications/notifications.module.ts +++ b/backend/src/modules/notifications/notifications.module.ts @@ -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 {} diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index 24adbf8d6..ae8db4fe6 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -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; + }) { + 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 */ diff --git a/backend/src/modules/savings/entities/savings-goal.entity.ts b/backend/src/modules/savings/entities/savings-goal.entity.ts index 7d4b8cc6c..1eeb31aee 100644 --- a/backend/src/modules/savings/entities/savings-goal.entity.ts +++ b/backend/src/modules/savings/entities/savings-goal.entity.ts @@ -138,6 +138,15 @@ export class SavingsGoal { @Column({ type: 'jsonb', nullable: true }) metadata: SavingsGoalMetadata | null; + /** + * Tracks which milestone notifications have been sent for this goal. + * Stored as a JSONB object with milestone percentage keys (e.g. "25", "50") + * and ISO timestamp values when the notification was sent. This avoids + * creating an additional table and keeps per-goal state colocated. + */ + @Column({ type: 'jsonb', nullable: true, default: () => "'{}'" }) + milestonesSent: Record | null; + // ── Audit timestamps ──────────────────────────────────────────────────────── @CreateDateColumn() From 8b347456c3e9ee36c04d71239cf00e0af2eca95a Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Sun, 29 Mar 2026 12:08:10 +0100 Subject: [PATCH 2/3] Commit before rebase --- frontend/app/dashboard/settings/page.tsx | 115 ++++++++++++++++++++++- 1 file changed, 110 insertions(+), 5 deletions(-) diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx index 81c3870fe..17db40586 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -1,9 +1,60 @@ -import React from "react"; +"use client"; + +import React, { useEffect, useState } from "react"; import { Settings } from "lucide-react"; export const metadata = { title: "Settings – Nestera" }; +type Prefs = { + emailNotifications?: boolean; + inAppNotifications?: boolean; + sweepNotifications?: boolean; + claimNotifications?: boolean; + yieldNotifications?: boolean; + milestoneNotifications?: boolean; +}; + export default function SettingsPage() { + const [prefs, setPrefs] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + const load = async () => { + try { + const res = await fetch("/notifications/preferences", { + credentials: "include", + }); + if (res.ok) { + const data = await res.json(); + setPrefs(data); + } + } catch (e) { + // ignore for now + } + }; + load(); + }, []); + + const toggle = (key: keyof Prefs) => { + setPrefs((p) => (p ? { ...p, [key]: !p[key] } : p)); + }; + + const save = async () => { + if (!prefs) return; + setSaving(true); + try { + await fetch("/notifications/preferences", { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(prefs), + }); + } catch (e) { + // ignore + } + setSaving(false); + }; + return (
@@ -18,10 +69,64 @@ export default function SettingsPage() {
-
-

- Account settings will appear here. -

+
+

Notifications

+
+ + + + + + +
+ +
+
); From bc91bd338ba2efbae83c8f71eb919dc5a62f11ec Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Sun, 29 Mar 2026 12:27:31 +0100 Subject: [PATCH 3/3] feat: add savings goal milestone notification --- .../app/dashboard/settings/SettingsClient.tsx | 131 ++++++++++++++++++ frontend/app/dashboard/settings/page.tsx | 131 +----------------- 2 files changed, 134 insertions(+), 128 deletions(-) create mode 100644 frontend/app/dashboard/settings/SettingsClient.tsx diff --git a/frontend/app/dashboard/settings/SettingsClient.tsx b/frontend/app/dashboard/settings/SettingsClient.tsx new file mode 100644 index 000000000..b19f60602 --- /dev/null +++ b/frontend/app/dashboard/settings/SettingsClient.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Settings } from "lucide-react"; + +type Prefs = { + emailNotifications?: boolean; + inAppNotifications?: boolean; + sweepNotifications?: boolean; + claimNotifications?: boolean; + yieldNotifications?: boolean; + milestoneNotifications?: boolean; +}; + +export default function SettingsClient() { + const [prefs, setPrefs] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + const load = async () => { + try { + const res = await fetch("/notifications/preferences", { + credentials: "include", + }); + if (res.ok) { + const data = await res.json(); + setPrefs(data); + } + } catch (e) { + // ignore for now + } + }; + load(); + }, []); + + const toggle = (key: keyof Prefs) => { + setPrefs((p) => (p ? { ...p, [key]: !p[key] } : p)); + }; + + const save = async () => { + if (!prefs) return; + setSaving(true); + try { + await fetch("/notifications/preferences", { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(prefs), + }); + } catch (e) { + // ignore + } + setSaving(false); + }; + + return ( +
+
+
+ +
+
+

Settings

+

+ Manage your account preferences +

+
+
+ +
+

Notifications

+
+ + + + + + +
+ +
+
+
+
+ ); +} diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx index 17db40586..79f391937 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -1,133 +1,8 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { Settings } from "lucide-react"; +import React from "react"; +import SettingsClient from "./SettingsClient"; export const metadata = { title: "Settings – Nestera" }; -type Prefs = { - emailNotifications?: boolean; - inAppNotifications?: boolean; - sweepNotifications?: boolean; - claimNotifications?: boolean; - yieldNotifications?: boolean; - milestoneNotifications?: boolean; -}; - export default function SettingsPage() { - const [prefs, setPrefs] = useState(null); - const [saving, setSaving] = useState(false); - - useEffect(() => { - const load = async () => { - try { - const res = await fetch("/notifications/preferences", { - credentials: "include", - }); - if (res.ok) { - const data = await res.json(); - setPrefs(data); - } - } catch (e) { - // ignore for now - } - }; - load(); - }, []); - - const toggle = (key: keyof Prefs) => { - setPrefs((p) => (p ? { ...p, [key]: !p[key] } : p)); - }; - - const save = async () => { - if (!prefs) return; - setSaving(true); - try { - await fetch("/notifications/preferences", { - method: "PATCH", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(prefs), - }); - } catch (e) { - // ignore - } - setSaving(false); - }; - - return ( -
-
-
- -
-
-

Settings

-

- Manage your account preferences -

-
-
- -
-

Notifications

-
- - - - - - -
- -
-
-
-
- ); + return ; }