diff --git a/server/.env.example b/server/.env.example index d23efce..f920e58 100644 --- a/server/.env.example +++ b/server/.env.example @@ -2,6 +2,9 @@ PORT=3001 APP_BASE_URL="https://stellovault.com" DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/stellovault?schema=public" +# SendGrid Configuration (for email notifications) +SENDGRID_API_KEY="" +FROM_EMAIL="noreply@stellovault.com" # Redis Configuration (for transaction queue) REDIS_HOST="localhost" REDIS_PORT="6379" diff --git a/server/package-lock.json b/server/package-lock.json index 333ec63..2895abe 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,6 +11,7 @@ "@prisma/adapter-pg": "^7.4.1", "@prisma/client": "^7.4.1", "@prisma/config": "^7.4.0", + "@sendgrid/mail": "^8.1.6", "@stellar/stellar-sdk": "^12.3.0", "bullmq": "^5.71.1", "cors": "^2.8.5", @@ -1793,6 +1794,44 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@sendgrid/client": { + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.6.tgz", + "integrity": "sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==", + "license": "MIT", + "dependencies": { + "@sendgrid/helpers": "^8.0.0", + "axios": "^1.12.0" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/@sendgrid/helpers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz", + "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/@sendgrid/mail": { + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.6.tgz", + "integrity": "sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==", + "license": "MIT", + "dependencies": { + "@sendgrid/client": "^8.1.5", + "@sendgrid/helpers": "^8.0.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -3544,7 +3583,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" diff --git a/server/package.json b/server/package.json index d4f65f7..e842dca 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "@prisma/adapter-pg": "^7.4.1", "@prisma/client": "^7.4.1", "@prisma/config": "^7.4.0", + "@sendgrid/mail": "^8.1.6", "@stellar/stellar-sdk": "^12.3.0", "bullmq": "^5.71.1", "cors": "^2.8.5", diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index a0c1108..9989360 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -25,9 +25,10 @@ model User { soldEscrows Escrow[] @relation("SellerEscrows") investments Investment[] sessions Session[] - borrowedLoans Loan[] @relation("BorrowerLoans") - lentLoans Loan[] @relation("LenderLoans") - governanceProposals GovernanceProposal[] @relation("ProposerProposals") + borrowedLoans Loan[] @relation("BorrowerLoans") + lentLoans Loan[] @relation("LenderLoans") + governanceProposals GovernanceProposal[] @relation("ProposerProposals") + notificationPrefs NotificationPreference? // RBAC relationships userPermissions UserPermission[] @@ -483,3 +484,27 @@ enum DisputeStatus { RESOLVED CANCELLED } + +// ───────────────────────────────────────────── +// Notifications +// ───────────────────────────────────────────── + +model NotificationPreference { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + loanApproved Boolean @default(true) + loanRepaymentDue Boolean @default(true) + escrowExpiringSoon Boolean @default(true) + escrowReleased Boolean @default(true) + securityAlert Boolean @default(true) + collateralLocked Boolean @default(true) + oracleConfirmation Boolean @default(true) + governanceProposal Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} diff --git a/server/src/services/loan.service.ts b/server/src/services/loan.service.ts index 013dbcf..5b5f0d3 100644 --- a/server/src/services/loan.service.ts +++ b/server/src/services/loan.service.ts @@ -9,6 +9,7 @@ import { xdr } from "@stellar/stellar-sdk"; import contractService from "./contract.service"; import { prisma } from "./database.service"; import websocketService from "./websocket.service"; +import notificationService, { NotificationType, NotificationChannel } from "./notification.service"; import { env } from "../config/env"; import Decimal from "decimal.js"; import eventMonitoringService from "./event-monitoring.service"; @@ -415,6 +416,57 @@ export class LoanService { throw new NotFoundError("Loan not found"); } + const outstandingAfter = outstandingBefore.minus(amount); + let nextStatus: LoanStatus = loan.status; + if (outstandingAfter.eq(ZERO)) { + nextStatus = "REPAID"; + } else if (loan.status === "PENDING") { + nextStatus = "ACTIVE"; + } + + if (nextStatus !== loan.status) { + await tx.loan.update({ + where: { id: loanId }, + data: { status: nextStatus }, + }); + + websocketService.broadcastLoanUpdated(loanId, nextStatus); + + // Send notification if loan is approved (moved to ACTIVE) + if (nextStatus === "ACTIVE") { + await notificationService.sendNotification({ + userId: loan.borrowerId, + type: NotificationType.LOAN_APPROVED, + channel: NotificationChannel.BOTH, + data: { + loanId, + amount: loan.amount.toString(), + assetCode: loan.assetCode, + interestRate: loan.interestRate.toString(), + dueDate: loan.dueDate?.toISOString() || "N/A", + dashboardUrl: `${process.env.FRONTEND_URL || "https://stellovault.com"}/loans/${loanId}`, + }, + }); + } + } + + const updatedLoan = await tx.loan.findUnique({ + where: { id: loanId }, + include: { repayments: true, borrower: true, lender: true }, + }); + if (!updatedLoan) { + throw new NotFoundError("Loan not found"); + } + + return { + repayment, + outstandingBefore: outstandingBefore.toString(), + outstandingAfter: outstandingAfter.toString(), + fullyRepaid: outstandingAfter.eq(ZERO), + loan: updatedLoan, + }; + }, { isolationLevel: "Serializable" }); + } return { repayment, paymentSession: selectedPaymentSession, diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts new file mode 100644 index 0000000..0b2eb1f --- /dev/null +++ b/server/src/services/notification.service.ts @@ -0,0 +1,395 @@ +import sgMail from "@sendgrid/mail"; +import { EventEmitter } from "events"; +import websocketService from "./websocket.service"; +import { prisma } from "./database.service"; + +const SENDGRID_API_KEY = process.env.SENDGRID_API_KEY || ""; +const FROM_EMAIL = process.env.FROM_EMAIL || "noreply@stellovault.com"; +const NOTIFICATION_TIMEOUT_MS = 60000; // 60 seconds + +if (SENDGRID_API_KEY) { + sgMail.setApiKey(SENDGRID_API_KEY); +} + +export enum NotificationType { + LOAN_APPROVED = "LOAN_APPROVED", + LOAN_REPAYMENT_DUE = "LOAN_REPAYMENT_DUE", + ESCROW_EXPIRING_SOON = "ESCROW_EXPIRING_SOON", + ESCROW_RELEASED = "ESCROW_RELEASED", + SECURITY_ALERT = "SECURITY_ALERT", + COLLATERAL_LOCKED = "COLLATERAL_LOCKED", + ORACLE_CONFIRMATION = "ORACLE_CONFIRMATION", + GOVERNANCE_PROPOSAL = "GOVERNANCE_PROPOSAL", +} + +export enum NotificationChannel { + EMAIL = "EMAIL", + WEBSOCKET = "WEBSOCKET", + BOTH = "BOTH", +} + +interface NotificationPayload { + userId: string; + type: NotificationType; + channel: NotificationChannel; + data: Record; + priority?: "low" | "normal" | "high"; +} + +interface EmailTemplate { + subject: string; + html: string; + text: string; +} + +export class NotificationService extends EventEmitter { + private notificationQueue: NotificationPayload[] = []; + private processing = false; + + constructor() { + super(); + this.startProcessor(); + } + + /** + * Send a notification to a user + */ + async sendNotification(payload: NotificationPayload): Promise { + const { userId, type, channel, data, priority = "normal" } = payload; + + // Check user preferences + const preferences = await this.getUserPreferences(userId); + if (!this.shouldSendNotification(type, preferences)) { + console.log(`User ${userId} has opted out of ${type} notifications`); + return; + } + + // Add to queue for async processing + this.notificationQueue.push(payload); + + // Emit event for monitoring + this.emit("notification:queued", { userId, type, channel }); + + // Process queue + this.processQueue(); + } + + private async processQueue(): Promise { + if (this.processing || this.notificationQueue.length === 0) { + return; + } + + this.processing = true; + + while (this.notificationQueue.length > 0) { + const payload = this.notificationQueue.shift(); + if (!payload) continue; + + try { + await this.deliverNotification(payload); + } catch (error) { + console.error(`Failed to deliver notification:`, error); + this.emit("notification:failed", { payload, error }); + } + } + + this.processing = false; + } + + private async deliverNotification(payload: NotificationPayload): Promise { + const { userId, type, channel, data } = payload; + const startTime = Date.now(); + + const promises: Promise[] = []; + + // Send via WebSocket (real-time) + if (channel === NotificationChannel.WEBSOCKET || channel === NotificationChannel.BOTH) { + promises.push(this.sendWebSocketNotification(userId, type, data)); + } + + // Send via Email (asynchronous) + if (channel === NotificationChannel.EMAIL || channel === NotificationChannel.BOTH) { + promises.push(this.sendEmailNotification(userId, type, data)); + } + + await Promise.allSettled(promises); + + const duration = Date.now() - startTime; + console.log(`Notification ${type} delivered to user ${userId} in ${duration}ms`); + + // Check if within 60s SLA + if (duration > NOTIFICATION_TIMEOUT_MS) { + console.warn(`⚠️ Notification ${type} exceeded 60s SLA: ${duration}ms`); + } + + this.emit("notification:delivered", { userId, type, duration }); + } + + private async sendWebSocketNotification( + userId: string, + type: NotificationType, + data: Record + ): Promise { + // Send real-time notification via WebSocket + websocketService.broadcast({ + type: "NOTIFICATION", + notificationType: type, + userId, + data, + timestamp: new Date().toISOString(), + }); + } + + private async sendEmailNotification( + userId: string, + type: NotificationType, + data: Record + ): Promise { + if (!SENDGRID_API_KEY) { + console.warn("SendGrid API key not configured, skipping email"); + return; + } + + // Get user email + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { stellarAddress: true, name: true }, + }); + + if (!user) { + throw new Error(`User ${userId} not found`); + } + + // For demo purposes, use stellar address as email + // In production, you'd have a separate email field + const userEmail = `${user.stellarAddress.substring(0, 8)}@example.com`; + + const template = this.getEmailTemplate(type, data, user.name || "User"); + + const msg = { + to: userEmail, + from: FROM_EMAIL, + subject: template.subject, + text: template.text, + html: template.html, + }; + + try { + await sgMail.send(msg); + console.log(`✓ Email sent to ${userEmail} for ${type}`); + } catch (error: any) { + console.error(`✗ Failed to send email:`, error.response?.body || error.message); + throw error; + } + } + + private getEmailTemplate( + type: NotificationType, + data: Record, + userName: string + ): EmailTemplate { + switch (type) { + case NotificationType.LOAN_APPROVED: + return { + subject: "🎉 Your Loan Has Been Approved!", + html: ` +

Congratulations, ${userName}!

+

Your loan application has been approved.

+
    +
  • Loan ID: ${data.loanId}
  • +
  • Amount: ${data.amount} ${data.assetCode}
  • +
  • Interest Rate: ${data.interestRate}%
  • +
  • Due Date: ${data.dueDate}
  • +
+

The funds will be transferred to your account shortly.

+

View Loan Details

+ `, + text: `Congratulations ${userName}! Your loan (${data.loanId}) for ${data.amount} ${data.assetCode} has been approved.`, + }; + + case NotificationType.LOAN_REPAYMENT_DUE: + return { + subject: "⏰ Loan Repayment Due Soon", + html: ` +

Repayment Reminder

+

Hi ${userName},

+

Your loan repayment is due soon.

+
    +
  • Loan ID: ${data.loanId}
  • +
  • Amount Due: ${data.amountDue} ${data.assetCode}
  • +
  • Due Date: ${data.dueDate}
  • +
  • Days Remaining: ${data.daysRemaining}
  • +
+

Please ensure you have sufficient funds to avoid late fees.

+

Make Payment

+ `, + text: `Hi ${userName}, your loan repayment of ${data.amountDue} ${data.assetCode} is due on ${data.dueDate}.`, + }; + + case NotificationType.ESCROW_EXPIRING_SOON: + return { + subject: "⚠️ Escrow Expiring Soon", + html: ` +

Escrow Expiration Notice

+

Hi ${userName},

+

Your escrow is expiring soon.

+
    +
  • Escrow ID: ${data.escrowId}
  • +
  • Amount: ${data.amount} ${data.assetCode}
  • +
  • Expires At: ${data.expiresAt}
  • +
  • Hours Remaining: ${data.hoursRemaining}
  • +
+

Please take action before expiration to avoid automatic refund.

+

View Escrow

+ `, + text: `Hi ${userName}, your escrow (${data.escrowId}) expires on ${data.expiresAt}.`, + }; + + case NotificationType.ESCROW_RELEASED: + return { + subject: "✅ Escrow Funds Released", + html: ` +

Funds Released

+

Hi ${userName},

+

The escrow funds have been successfully released.

+
    +
  • Escrow ID: ${data.escrowId}
  • +
  • Amount: ${data.amount} ${data.assetCode}
  • +
  • Recipient: ${data.recipient}
  • +
  • Transaction Hash: ${data.txHash}
  • +
+

The transaction has been confirmed on the Stellar network.

+

View on Explorer

+ `, + text: `Escrow ${data.escrowId} released: ${data.amount} ${data.assetCode} to ${data.recipient}.`, + }; + + case NotificationType.SECURITY_ALERT: + return { + subject: "🔒 Security Alert - Action Required", + html: ` +

Security Alert

+

Hi ${userName},

+

We detected unusual activity on your account.

+
    +
  • Alert Type: ${data.alertType}
  • +
  • Time: ${data.timestamp}
  • +
  • IP Address: ${data.ipAddress}
  • +
  • Location: ${data.location}
  • +
+

If this was you, no action is needed. Otherwise, please secure your account immediately.

+

Review Security Settings

+ `, + text: `Security Alert: Unusual activity detected on your account at ${data.timestamp}. Review immediately.`, + }; + + case NotificationType.COLLATERAL_LOCKED: + return { + subject: "🔐 Collateral Locked Successfully", + html: ` +

Collateral Locked

+

Hi ${userName},

+

Your collateral has been successfully locked for the escrow.

+
    +
  • Collateral ID: ${data.collateralId}
  • +
  • Amount: ${data.amount} ${data.assetCode}
  • +
  • Escrow ID: ${data.escrowId}
  • +
+

Your collateral is now secured and will be released upon escrow completion.

+ `, + text: `Collateral ${data.collateralId} locked for escrow ${data.escrowId}.`, + }; + + case NotificationType.ORACLE_CONFIRMATION: + return { + subject: "✓ Oracle Confirmation Received", + html: ` +

Oracle Confirmation

+

Hi ${userName},

+

An oracle has confirmed the event for your escrow.

+
    +
  • Escrow ID: ${data.escrowId}
  • +
  • Event Type: ${data.eventType}
  • +
  • Oracle: ${data.oracleAddress}
  • +
  • Status: ${data.status}
  • +
+

The escrow will proceed according to the confirmation.

+ `, + text: `Oracle confirmed ${data.eventType} for escrow ${data.escrowId}.`, + }; + + case NotificationType.GOVERNANCE_PROPOSAL: + return { + subject: "🗳️ New Governance Proposal", + html: ` +

New Proposal to Vote On

+

Hi ${userName},

+

A new governance proposal has been created.

+
    +
  • Proposal ID: ${data.proposalId}
  • +
  • Title: ${data.title}
  • +
  • Voting Ends: ${data.endsAt}
  • +
+

${data.description}

+

Cast Your Vote

+ `, + text: `New governance proposal: ${data.title}. Vote by ${data.endsAt}.`, + }; + + default: + return { + subject: "Notification from StelloVault", + html: `

Hi ${userName},

You have a new notification.

`, + text: `Hi ${userName}, you have a new notification.`, + }; + } + } + + private async getUserPreferences(userId: string): Promise> { + // In production, fetch from database + // For now, return default preferences (all enabled) + return { + [NotificationType.LOAN_APPROVED]: true, + [NotificationType.LOAN_REPAYMENT_DUE]: true, + [NotificationType.ESCROW_EXPIRING_SOON]: true, + [NotificationType.ESCROW_RELEASED]: true, + [NotificationType.SECURITY_ALERT]: true, + [NotificationType.COLLATERAL_LOCKED]: true, + [NotificationType.ORACLE_CONFIRMATION]: true, + [NotificationType.GOVERNANCE_PROPOSAL]: true, + }; + } + + private shouldSendNotification( + type: NotificationType, + preferences: Record + ): boolean { + // Security alerts always sent regardless of preferences + if (type === NotificationType.SECURITY_ALERT) { + return true; + } + + return preferences[type] !== false; + } + + private startProcessor(): void { + // Process queue every 5 seconds + setInterval(() => { + this.processQueue(); + }, 5000); + } + + /** + * Update user notification preferences + */ + async updatePreferences( + userId: string, + preferences: Partial> + ): Promise { + // In production, save to database + console.log(`Updated preferences for user ${userId}:`, preferences); + this.emit("preferences:updated", { userId, preferences }); + } +} + +export default new NotificationService();