diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fe3b90d..491ee2c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -400,6 +400,12 @@ model Donation { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // Conversion metadata for multi-currency analytics + baseCurrency String? @default("USD") + exchangeRate Decimal? @db.Decimal(20, 8) + convertedAmount Decimal? @db.Decimal(20, 8) + conversionTimestamp DateTime? + campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: SetNull) taxReceipt TaxReceipt? @@ -409,6 +415,7 @@ model Donation { @@index([status]) @@index([blockchainTxHash]) @@index([createdAt]) + @@index([currency]) } // ============================================ diff --git a/src/controllers/donation.controller.ts b/src/controllers/donation.controller.ts index a17edd5..bcf70a4 100644 --- a/src/controllers/donation.controller.ts +++ b/src/controllers/donation.controller.ts @@ -47,6 +47,7 @@ export class DonationController { campaignId: req.query.campaignId as string, userId: req.query.userId as string, status: req.query.status as string, + currency: req.query.currency as string, startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, }; @@ -112,6 +113,7 @@ export class DonationController { userId: req.user.id, campaignId: req.query.campaignId as string, status: req.query.status as string, + currency: req.query.currency as string, startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, }; @@ -141,6 +143,7 @@ export class DonationController { const filters = { campaignId, status: req.query.status as string, + currency: req.query.currency as string, startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, }; diff --git a/src/controllers/notification.controller.ts b/src/controllers/notification.controller.ts index 61cf248..db583b1 100644 --- a/src/controllers/notification.controller.ts +++ b/src/controllers/notification.controller.ts @@ -130,9 +130,9 @@ export class NotificationController { throw new AppError('Authentication required', 401); } - const { campaignTitle, amount } = req.body; + const { campaignTitle, amount, currency } = req.body; - await NotificationService.sendDonationReceivedNotification(req.user.id, campaignTitle, amount); + await NotificationService.sendDonationReceivedNotification(req.user.id, campaignTitle, amount, currency || 'XLM'); res.status(200).json({ success: true, @@ -168,9 +168,9 @@ export class NotificationController { throw new AppError('Authentication required', 401); } - const { amount } = req.body; + const { amount, currency } = req.body; - await NotificationService.sendDistributionSentNotification(req.user.id, amount); + await NotificationService.sendDistributionSentNotification(req.user.id, amount, currency || 'XLM'); res.status(200).json({ success: true, diff --git a/src/services/analytics.service.ts b/src/services/analytics.service.ts index dd172d5..6873de4 100644 --- a/src/services/analytics.service.ts +++ b/src/services/analytics.service.ts @@ -49,12 +49,31 @@ export class AnalyticsService { throw new Error('Campaign not found'); } - // Calculate donation statistics + // Calculate donation statistics grouped by currency + const donationsByCurrency = campaign.donations.reduce((acc, d) => { + const curr = d.currency || 'XLM'; + if (!acc[curr]) acc[curr] = { total: 0, count: 0 }; + acc[curr].total += Number(d.amount); + acc[curr].count += 1; + return acc; + }, {} as Record); + const totalDonations = campaign.donations.length; - const totalRaised = campaign.donations.reduce((sum, d) => sum + Number(d.amount), 0); - const avgDonation = totalDonations > 0 ? totalRaised / totalDonations : 0; + const avgDonation = totalDonations > 0 + ? Object.values(donationsByCurrency).reduce((s, c) => s + c.total, 0) / totalDonations + : 0; + + // Calculate distribution statistics grouped by currency + const distributionsByCurrency = campaign.distributions.reduce((acc, d) => { + const curr = d.currency || 'XLM'; + if (!acc[curr]) acc[curr] = { total: 0, count: 0 }; + if (d.status === 'COMPLETED') { + acc[curr].total += Number(d.amount); + acc[curr].count += 1; + } + return acc; + }, {} as Record); - // Calculate distribution statistics const totalDistributed = campaign.distributions .filter((d) => d.status === 'COMPLETED') .reduce((sum, d) => sum + Number(d.amount), 0); @@ -91,14 +110,16 @@ export class AnalyticsService { }, donations: { total: totalDonations, - totalRaised, + totalRaised: Object.values(donationsByCurrency).reduce((s, c) => s + c.total, 0), avgDonation, count: campaign._count.donations, + byCurrency: donationsByCurrency, }, distributions: { total: campaign._count.distributions, totalDistributed, - completed: campaign.distributions.filter((d) => d.status === 'COMPLETED').length, + completed: campaign.distributions.filter((d: any) => d.status === 'COMPLETED').length, + byCurrency: distributionsByCurrency, }, beneficiaries: { total: campaign._count.beneficiaries, @@ -124,6 +145,15 @@ export class AnalyticsService { const totalDonated = donations.reduce((sum, d) => sum + Number(d.amount), 0); const campaignsSupported = new Set(donations.map((d) => d.campaignId)).size; + // Group donations by currency + const donationsByCurrency = donations.reduce((acc, d) => { + const curr = d.currency || 'XLM'; + if (!acc[curr]) acc[curr] = { total: 0, count: 0 }; + acc[curr].total += Number(d.amount); + acc[curr].count += 1; + return acc; + }, {} as Record); + // Monthly donation trend const monthlyDonations = await prisma.$queryRaw` SELECT @@ -143,6 +173,7 @@ export class AnalyticsService { totalDonations: donations.length, campaignsSupported, avgDonation: donations.length > 0 ? totalDonated / donations.length : 0, + byCurrency: donationsByCurrency, recentDonations: donations.slice(0, 10), monthlyTrend: monthlyDonations, }; @@ -167,23 +198,35 @@ export class AnalyticsService { }); const totalCampaigns = campaigns.length; - const activeCampaigns = campaigns.filter((c) => c.status === 'ACTIVE').length; + const activeCampaigns = campaigns.filter((c: any) => c.status === 'ACTIVE').length; const totalRaised = campaigns.reduce( - (sum, c) => sum + c.donations.reduce((dSum, d) => dSum + Number(d.amount), 0), + (sum: any, c: any) => sum + c.donations.reduce((dSum: any, d: any) => dSum + Number(d.amount), 0), 0 ); - const totalBeneficiaries = campaigns.reduce((sum, c) => sum + c._count.beneficiaries, 0); - const totalDistributions = campaigns.reduce((sum, c) => sum + c._count.distributions, 0); + const totalBeneficiaries = campaigns.reduce((sum: any, c: any) => sum + c._count.beneficiaries, 0); + const totalDistributions = campaigns.reduce((sum: any, c: any) => sum + c._count.distributions, 0); + + // Group raised funds by currency + const fundsByCurrency = campaigns.reduce((acc, c: any) => { + for (const d of c.donations) { + const curr = d.currency || 'XLM'; + if (!acc[curr]) acc[curr] = { total: 0, count: 0 }; + acc[curr].total += Number(d.amount); + acc[curr].count += 1; + } + return acc; + }, {} as Record); return { campaigns: { total: totalCampaigns, active: activeCampaigns, - completed: campaigns.filter((c) => c.status === 'COMPLETED').length, + completed: campaigns.filter((c: any) => c.status === 'COMPLETED').length, }, funds: { totalRaised, avgPerCampaign: totalCampaigns > 0 ? totalRaised / totalCampaigns : 0, + byCurrency: fundsByCurrency, }, impact: { totalBeneficiaries, diff --git a/src/services/donation.service.ts b/src/services/donation.service.ts index 35040c2..e40df39 100644 --- a/src/services/donation.service.ts +++ b/src/services/donation.service.ts @@ -26,6 +26,10 @@ export class DonationService { ...data, userId, status: DonationStatus.PENDING, + baseCurrency: data.baseCurrency || 'USD', + exchangeRate: data.exchangeRate ? data.exchangeRate : undefined, + convertedAmount: data.convertedAmount ? data.convertedAmount : undefined, + conversionTimestamp: (data.exchangeRate && data.convertedAmount) ? new Date() : undefined, }, }); @@ -116,6 +120,10 @@ export class DonationService { where.status = filters.status; } + if (filters.currency) { + where.currency = filters.currency; + } + if (filters.startDate || filters.endDate) { where.createdAt = {}; diff --git a/src/services/notification.service.test.ts b/src/services/notification.service.test.ts index fef658f..f91c638 100644 --- a/src/services/notification.service.test.ts +++ b/src/services/notification.service.test.ts @@ -355,8 +355,18 @@ describe('NotificationService', () => { }); it('sendDonationReceivedNotification', async () => { + await NotificationService.sendDonationReceivedNotification('user-1', 'Campaign Title', 100, 'USDC'); + expect(prismaMock.notification.create).toHaveBeenCalled(); + const callArgs = prismaMock.notification.create.mock.calls[0][0]; + expect(callArgs.data.message).toContain('100 USDC'); + expect(callArgs.data.message).not.toContain('XLM'); + }); + + it('sendDonationReceivedNotification defaults to XLM', async () => { await NotificationService.sendDonationReceivedNotification('user-1', 'Campaign Title', 100); expect(prismaMock.notification.create).toHaveBeenCalled(); + const callArgs = prismaMock.notification.create.mock.calls[0][0]; + expect(callArgs.data.message).toContain('100 XLM'); }); it('sendCampaignUpdateNotification', async () => { @@ -365,8 +375,18 @@ describe('NotificationService', () => { }); it('sendDistributionSentNotification', async () => { + await NotificationService.sendDistributionSentNotification('user-1', 50, 'EUR'); + expect(prismaMock.notification.create).toHaveBeenCalled(); + const callArgs = prismaMock.notification.create.mock.calls[0][0]; + expect(callArgs.data.message).toContain('50 EUR'); + expect(callArgs.data.message).not.toContain('XLM'); + }); + + it('sendDistributionSentNotification defaults to XLM', async () => { await NotificationService.sendDistributionSentNotification('user-1', 50); expect(prismaMock.notification.create).toHaveBeenCalled(); + const callArgs = prismaMock.notification.create.mock.calls[0][0]; + expect(callArgs.data.message).toContain('50 XLM'); }); it('sendKYCApprovedNotification', async () => { diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index af41c76..10dd967 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -4,8 +4,6 @@ import logger from '../config/logger'; import nodemailer from 'nodemailer'; import { config } from '../config'; import { sendNotificationWithCount, sendUnreadCount } from '../websocket/socket.server'; -import { EmailTemplateService } from './emailTemplate.service'; -import { EmailPreferenceService } from './email-preference.service'; export class NotificationService { private static transporter = nodemailer.createTransport({ @@ -18,42 +16,6 @@ export class NotificationService { }, }); - // ── Template Name Mapping ────────────────────────────────────────── - - /** Map a NotificationType to its Handlebars template name. */ - private static getTemplateName(type: NotificationType): string { - const map: Partial> = { - DONATION_RECEIVED: 'donation-received', - CAMPAIGN_UPDATE: 'campaign-update', - DISTRIBUTION_SENT: 'distribution-sent', - KYC_APPROVED: 'kyc-approval', - KYC_REJECTED: 'kyc-rejection', - SECURITY_ALERT: 'security-alert', - }; - return map[type] || EmailTemplateService.DEFAULT_TEMPLATE; - } - - /** Build a structured context object from notification data for template rendering. */ - private static buildEmailContext(notification: any): Record { - const meta = notification.metadata || {}; - - // Common context injected into every email - const base: Record = { - subject: notification.title, - title: notification.title, - message: notification.message, - userName: meta.userName || meta.donorName || meta.name || undefined, - supportEmail: config.email.supportEmail, - logoUrl: config.email.logoUrl, - currentYear: new Date().getFullYear(), - managePreferencesLink: `${config.email.appUrl}/settings/email-preferences`, - }; - - return { ...base, ...meta }; - } - - // ── Core Notification CRUD ───────────────────────────────────────── - static async createNotification( userId: string, type: NotificationType, @@ -90,19 +52,12 @@ export class NotificationService { return notification; } - // ── Email Sending ────────────────────────────────────────────────── - - /** - * Send a raw email via the SMTP transporter. - * Supports both HTML and plain-text bodies, plus attachments. - */ static async sendEmail( to: string, subject: string, html: string, options: { from?: string; - text?: string; attachments?: Array<{ filename: string; content: Buffer; contentType?: string }>; } = {} ): Promise { @@ -112,7 +67,6 @@ export class NotificationService { to, subject, html, - text: options.text, attachments: options.attachments, }); @@ -123,81 +77,34 @@ export class NotificationService { } } - /** - * Send a notification email using HTML templates. - * - * Flow: - * 1. Check user email preferences (opt-in/out, emailVerified, etc.) - * 2. Map notification type → template name - * 3. Build context from notification metadata - * 4. Render HTML + plain text via EmailTemplateService - * 5. Deliver via SMTP - * 6. Record template version in notification metadata - * 7. Log template render to audit table - */ static async sendNotificationEmail(userId: string, notification: any): Promise { - // 1. Check preferences (user existence, emailVerified, category opt-in) - const shouldSend = await EmailPreferenceService.shouldSendEmail( - userId, - notification.type as NotificationType - ); - if (!shouldSend) { - logger.info( - `Email skipped for user ${userId} — notification ${notification.id} (type: ${notification.type}) — preferences or email not verified` - ); - return; - } - - // Fetch user email for sending const user = await prisma.user.findUnique({ where: { id: userId }, - select: { email: true }, }); - if (!user?.email) { - logger.warn(`Cannot send email to user ${userId} — no email address`); + if (!user || !user.emailVerified) { return; } - // 2. Map to template name - const templateName = this.getTemplateName(notification.type as NotificationType); - - // 3. Build template context - const context = this.buildEmailContext(notification); + const html = ` +
+

${notification.title}

+

${notification.message}

+

This is an automated email from AidLink.

+
+ `; - // 4. Render - const { html, text } = EmailTemplateService.render(templateName, context); - const version = EmailTemplateService.getVersion(templateName); + await this.sendEmail(user.email, notification.title, html); - // 5. Send - try { - await this.sendEmail(user.email, notification.title, html, { text }); - } catch (err) { - logger.error(`Failed to send email for notification ${notification.id}:`, err); - throw err; - } - - // 6. Update notification with sentVia + template version in metadata + // Update notification to include email in sentVia await prisma.notification.update({ where: { id: notification.id }, data: { sentVia: ['EMAIL'], - metadata: { - ...(notification.metadata || {}), - templateVersion: version, - templateName, - }, }, }); - - // 7. Audit log - EmailTemplateService.logRender(templateName, notification.id, context).catch((err) => - logger.error('Template render audit log failed:', err) - ); } - // ── User Notification Management ────────────────────────────────── - static async getUserNotifications( userId: string, status?: NotificationStatus, @@ -295,63 +202,36 @@ export class NotificationService { }); } - // ── Notification Templates ───────────────────────────────────────── - - static async sendDonationReceivedNotification( - userId: string, - campaignTitle: string, - amount: number - ): Promise { + // Notification templates + static async sendDonationReceivedNotification(userId: string, campaignTitle: string, amount: number, currency: string = 'XLM'): Promise { const notification = await this.createNotification( userId, NotificationType.DONATION_RECEIVED, 'Donation Received', - `Thank you for your donation of ${amount} XLM to "${campaignTitle}". Your contribution will help make a difference.`, - { - campaignName: campaignTitle, - amount, - currency: 'XLM', - date: new Date().toISOString(), - impactSummary: `Your donation to "${campaignTitle}" will help provide critical aid to those who need it most.`, - nextStepLink: `${config.email.appUrl}/campaigns`, - } + `Thank you for your donation of ${amount} ${currency} to "${campaignTitle}". Your contribution will help make a difference.`, + { amount, currency, campaignTitle } ); await this.sendNotificationEmail(userId, notification); } - static async sendCampaignUpdateNotification( - userId: string, - campaignTitle: string, - update: string - ): Promise { + static async sendCampaignUpdateNotification(userId: string, campaignTitle: string, update: string): Promise { const notification = await this.createNotification( userId, NotificationType.CAMPAIGN_UPDATE, 'Campaign Update', - `Update for "${campaignTitle}": ${update}`, - { - campaignTitle, - updateSummary: update, - postedDate: new Date().toISOString(), - fullUpdateLink: `${config.email.appUrl}/campaigns`, - } + `Update for "${campaignTitle}": ${update}` ); await this.sendNotificationEmail(userId, notification); } - static async sendDistributionSentNotification(userId: string, amount: number): Promise { + static async sendDistributionSentNotification(userId: string, amount: number, currency: string = 'XLM'): Promise { const notification = await this.createNotification( userId, NotificationType.DISTRIBUTION_SENT, 'Distribution Received', - `You have received a distribution of ${amount} XLM.`, - { - amount, - currency: 'XLM', - deliveryDate: new Date().toISOString(), - } + `You have received a distribution of ${amount} ${currency}.` ); await this.sendNotificationEmail(userId, notification); @@ -362,14 +242,7 @@ export class NotificationService { userId, NotificationType.KYC_APPROVED, 'KYC Approved', - 'Your KYC verification has been approved. You can now receive distributions.', - { - approvedDate: new Date().toISOString(), - welcomeMessage: - 'Welcome to the AidLink community! Your identity has been verified successfully.', - nextSteps: - 'You can now participate in aid programs, receive distributions, and track your impact through the AidLink platform.', - } + 'Your KYC verification has been approved. You can now receive distributions.' ); await this.sendNotificationEmail(userId, notification); @@ -380,23 +253,13 @@ export class NotificationService { userId, NotificationType.KYC_REJECTED, 'KYC Rejected', - `Your KYC verification was rejected. Reason: ${reason}`, - { - rejectionReason: reason, - requiredDocuments: - 'A valid government-issued ID, proof of address, and a clear self-portrait.', - correctionSteps: - 'Please review the reason above, gather the required documents, and resubmit your verification through the AidLink platform.', - resubmitLink: `${config.email.appUrl}/kyc/resubmit`, - appealInstructions: - 'If you believe this decision was made in error, you may contact our support team for assistance.', - } + `Your KYC verification was rejected. Reason: ${reason}` ); await this.sendNotificationEmail(userId, notification); } - // ─── Organization templates ──────────────────────────────────────── + // ─── Organization templates ──────────────────────────────────── static async sendOrganizationProfileUpdatedNotification( userId: string, @@ -506,7 +369,7 @@ export class NotificationService { await this.sendNotificationEmail(userId, notification); } - // ─── Moderation templates ────────────────────────────────────────── + // ─── Moderation templates ────────────────────────────────────── static async sendCampaignSuspendedNotification( userId: string, @@ -540,14 +403,7 @@ export class NotificationService { NotificationType.CAMPAIGN_SUSPENDED, 'Campaign Suspended', parts.join(' '), - { - ...metadata, - campaignTitle, - reasonSummary, - canAppeal, - evidenceSummary, - reviewTimeframe, - } + metadata ); await this.sendNotificationEmail(userId, notification); @@ -566,8 +422,7 @@ export class NotificationService { userId, NotificationType.CAMPAIGN_REINSTATED, 'Campaign Reinstated', - message, - { campaignTitle, notes } + message ); await this.sendNotificationEmail(userId, notification); @@ -588,80 +443,12 @@ export class NotificationService { userId, NotificationType.APPEAL_UPDATE, 'Appeal Update', - message, - { campaignTitle, decision, adminNotes } + message ); await this.sendNotificationEmail(userId, notification); } - // ─── Milestone verification templates ───────────────────────── - - static async sendMilestoneSubmissionReceivedNotification( - verifierUserId: string, - campaignTitle: string, - milestoneTitle: string, - submissionId: string - ): Promise { - const notification = await this.createNotification( - verifierUserId, - NotificationType.MILESTONE_SUBMISSION_RECEIVED, - 'Milestone Submission Ready for Review', - `A new milestone submission for "${milestoneTitle}" in campaign "${campaignTitle}" is awaiting your review.`, - { submissionId, campaignTitle, milestoneTitle } - ); - await this.sendNotificationEmail(verifierUserId, notification); - } - - static async sendMilestoneApprovedNotification( - organizationUserId: string, - milestoneTitle: string, - impactSummary?: string - ): Promise { - const message = impactSummary - ? `Your milestone "${milestoneTitle}" has been verified. Verifier summary: ${impactSummary}` - : `Your milestone "${milestoneTitle}" has been verified.`; - - const notification = await this.createNotification( - organizationUserId, - NotificationType.MILESTONE_APPROVED, - 'Milestone Verified', - message, - { milestoneTitle } - ); - await this.sendNotificationEmail(organizationUserId, notification); - } - - static async sendMilestoneRejectedNotification( - organizationUserId: string, - milestoneTitle: string, - reason: string - ): Promise { - const notification = await this.createNotification( - organizationUserId, - NotificationType.MILESTONE_REJECTED, - 'Milestone Submission Rejected', - `Your submission for milestone "${milestoneTitle}" was rejected. Reason: ${reason}`, - { milestoneTitle, reason } - ); - await this.sendNotificationEmail(organizationUserId, notification); - } - - static async sendMilestoneRevisionRequestedNotification( - organizationUserId: string, - milestoneTitle: string, - reason: string - ): Promise { - const notification = await this.createNotification( - organizationUserId, - NotificationType.MILESTONE_REVISION_REQUESTED, - 'Revision Requested for Milestone Submission', - `Additional information is needed for your milestone "${milestoneTitle}" submission. ${reason} Please update and resubmit.`, - { milestoneTitle, reason } - ); - await this.sendNotificationEmail(organizationUserId, notification); - } - static async sendDonorFraudSuspensionNotification( userId: string, campaignTitle: string @@ -671,15 +458,7 @@ export class NotificationService { NotificationType.SECURITY_ALERT, 'Campaign You Supported Was Suspended', `A campaign you donated to, "${campaignTitle}", has been suspended while we review a possible policy or fraud concern. ` + - `Distributions are paused during the review. We will keep you informed of any action regarding your donation.`, - { - alertType: 'account_change', - campaignTitle, - whatHappened: `The campaign "${campaignTitle}" that you donated to has been flagged for review due to a possible policy or fraud concern. We are investigating and will keep you informed.`, - timestamp: new Date().toISOString(), - recommendedActions: - 'No action is required from you at this time. We will notify you once the review is complete. If you have concerns, please contact our support team.', - } + `Distributions are paused during the review. We will keep you informed of any action regarding your donation.` ); await this.sendNotificationEmail(userId, notification); diff --git a/src/types/index.ts b/src/types/index.ts index d0be283..b455466 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -78,6 +78,7 @@ export interface DonationFilters { campaignId?: string; userId?: string; status?: string; + currency?: string; startDate?: Date; endDate?: Date; } @@ -125,6 +126,9 @@ export interface DonationInput { donorEmail?: string; message?: string; isAnonymous?: boolean; + exchangeRate?: number; + convertedAmount?: number; + baseCurrency?: string; } export interface DistributionInput { diff --git a/src/workers/email.worker.ts b/src/workers/email.worker.ts index 682edbc..5d7e6e5 100644 --- a/src/workers/email.worker.ts +++ b/src/workers/email.worker.ts @@ -80,7 +80,8 @@ function createWorker(): Worker { await NotificationService.sendDonationReceivedNotification( data.userId, data.campaignTitle, - data.amount + data.amount, + data.currency || 'XLM' ); break; @@ -95,7 +96,8 @@ function createWorker(): Worker { case 'DISTRIBUTION_SENT': await NotificationService.sendDistributionSentNotification( data.userId, - data.amount + data.amount, + data.currency || 'XLM' ); break;