Skip to content
Open
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
3 changes: 3 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +6 to +7
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix dotenv-linter warnings on new env keys (likely CI blocker).

Line 6 and Line 7 introduce QuoteCharacter warnings, and key order triggers UnorderedKey. This can keep CI red.

Proposed fix
- SENDGRID_API_KEY=""
- FROM_EMAIL="noreply@stellovault.com"
+ FROM_EMAIL=noreply@stellovault.com
+ SENDGRID_API_KEY=
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
SENDGRID_API_KEY=""
FROM_EMAIL="noreply@stellovault.com"
FROM_EMAIL=noreply@stellovault.com
SENDGRID_API_KEY=
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 6-6: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 7-7: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 7-7: [UnorderedKey] The FROM_EMAIL key should go before the SENDGRID_API_KEY key

(UnorderedKey)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/.env.example` around lines 6 - 7, Remove the quote characters around
the new env values and reorder the keys to match the file's expected
alphabetical order to satisfy dotenv-linter: change SENDGRID_API_KEY="" and
FROM_EMAIL="noreply@stellovault.com" to unquoted values (empty values may be
left blank as SENDGRID_API_KEY= and FROM_EMAIL=noreply@stellovault.com) and
place them in the proper alphabetical position among the other keys so the
linter no longer reports QuoteCharacter or UnorderedKey warnings.

# Redis Configuration (for transaction queue)
REDIS_HOST="localhost"
REDIS_PORT="6379"
Expand Down
40 changes: 39 additions & 1 deletion server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 28 additions & 3 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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])
}
52 changes: 52 additions & 0 deletions server/src/services/loan.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the env config object for consistency.

The file imports env from "../config/env" (line 8) and uses it elsewhere (e.g., line 102), but this line accesses process.env.FRONTEND_URL directly. Using the centralized config ensures consistent validation and defaults.

-                            dashboardUrl: `${process.env.FRONTEND_URL || "https://stellovault.com"}/loans/${loanId}`,
+                            dashboardUrl: `${env.frontendUrl || "https://stellovault.com"}/loans/${loanId}`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/services/loan.service.ts` at line 251, Replace the direct
process.env access in the dashboardUrl construction with the centralized config
object imported as env (i.e., use env.FRONTEND_URL instead of
process.env.FRONTEND_URL) in the code that constructs dashboardUrl for the loan
(the object building dashboardUrl: `${...}/loans/${loanId}` inside
loan.service.ts); ensure you keep the same fallback/default behavior currently
provided and reference the env property used elsewhere in this file for
consistency.

},
});
}
Comment on lines +434 to +450
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Move notification dispatch outside the database transaction to avoid blocking and potential rollback.

The await notificationService.sendNotification() call is inside the db.$transaction() block (lines 187-272). If SendGrid is slow or fails, this will:

  1. Hold the serializable transaction open unnecessarily
  2. Risk transaction timeout under load
  3. Potentially roll back a successful database update due to email delivery failure

The notification should be queued after the transaction commits successfully.

🛠️ Proposed fix: dispatch notification after transaction
-            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}`,
-                        },
-                    });
-                }
-            }
+            let shouldNotifyApproval = false;
+            let notificationData: { borrowerId: string; loanId: string; amount: string; assetCode: string; interestRate: string; dueDate: string } | null = null;
+
+            if (nextStatus !== loan.status) {
+                await tx.loan.update({
+                    where: { id: loanId },
+                    data: { status: nextStatus },
+                });
+                
+                websocketService.broadcastLoanUpdated(loanId, nextStatus);
+
+                if (nextStatus === "ACTIVE") {
+                    shouldNotifyApproval = true;
+                    notificationData = {
+                        borrowerId: loan.borrowerId,
+                        loanId,
+                        amount: loan.amount.toString(),
+                        assetCode: loan.assetCode,
+                        interestRate: loan.interestRate.toString(),
+                        dueDate: loan.dueDate?.toISOString() || "N/A",
+                    };
+                }
+            }

Then after the transaction block (after line 272), add:

// Send notification after transaction commits
if (shouldNotifyApproval && notificationData) {
    notificationService.sendNotification({
        userId: notificationData.borrowerId,
        type: NotificationType.LOAN_APPROVED,
        channel: NotificationChannel.BOTH,
        data: {
            ...notificationData,
            dashboardUrl: `${env.frontendUrl || "https://stellovault.com"}/loans/${notificationData.loanId}`,
        },
    }).catch(err => console.error("Failed to send loan approval notification:", err));
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/services/loan.service.ts` around lines 238 - 254, The
sendNotification call inside db.$transaction is blocking and can cause
transaction timeouts or rollbacks; capture the notification payload and a flag
(e.g., shouldNotifyApproval and notificationData) inside the transaction (using
existing symbols like loan, loanId, NotificationType.LOAN_APPROVED,
NotificationChannel.BOTH) but do NOT call notificationService.sendNotification
there; after the db.$transaction completes, call
notificationService.sendNotification with the captured payload (and wrap it in a
.catch to log failures) so the DB commit is not tied to external delivery.

}

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,
Expand Down
Loading
Loading