This commit is contained in:
2026-05-10 18:25:58 +01:00
parent 165af509ef
commit e4f4992a1b
31 changed files with 2708 additions and 495 deletions

View File

@@ -23,7 +23,11 @@
import { ethers } from "ethers";
import { createHash } from "crypto";
import type { BlockchainProof, BlockchainVerification, BlockchainStats } from "./blockchain.types";
import type {
BlockchainProof,
BlockchainVerification,
BlockchainStats,
} from "./blockchain.types";
// ─────────────────────────────────────────────────
// Smart Contract ABI (Application Binary Interface)
@@ -102,7 +106,7 @@ function getReadContract(): ethers.Contract {
_readContract = new ethers.Contract(
contractAddress,
DOCUMENT_REGISTRY_ABI,
getProvider()
getProvider(),
);
}
return _readContract;
@@ -117,7 +121,7 @@ function getWriteContract(): ethers.Contract {
_writeContract = new ethers.Contract(
contractAddress,
DOCUMENT_REGISTRY_ABI,
getWallet()
getWallet(),
);
}
return _writeContract;
@@ -214,7 +218,7 @@ export class BlockchainService {
*/
static async registerOnChain(
documentHash: string,
fileName: string
fileName: string,
): Promise<BlockchainProof> {
if (!this.isConfigured()) {
throw new Error("Blockchain not configured. Check your .env variables.");
@@ -307,7 +311,7 @@ export class BlockchainService {
* @returns Verification result with existence, timestamp, depositor
*/
static async verifyOnChain(
documentHash: string
documentHash: string,
): Promise<BlockchainVerification> {
if (!this.isReadConfigured()) {
throw new Error("Blockchain read access not configured");
@@ -331,7 +335,7 @@ export class BlockchainService {
*/
static async hashAndRegister(
fileUrl: string,
fileName: string
fileName: string,
): Promise<BlockchainProof> {
const documentHash = await this.hashDocument(fileUrl);
return await this.registerOnChain(documentHash, fileName);
@@ -368,13 +372,14 @@ export class BlockchainService {
const [blockNumber, totalDocs, networkObj] = await Promise.all([
provider.getBlockNumber(),
contract.totalDocuments(),
provider.getNetwork()
provider.getNetwork(),
]);
return {
totalVerified: Number(totalDocs),
latestBlockNumber: blockNumber,
networkName: config.network === "sepolia" ? "Ethereum Sepolia" : "Hardhat Local",
networkName:
config.network === "sepolia" ? "Ethereum Sepolia" : "Hardhat Local",
networkStatus: "connected",
walletAddress,
chainId: Number(networkObj.chainId),
@@ -384,7 +389,8 @@ export class BlockchainService {
return {
totalVerified: 0,
latestBlockNumber: null,
networkName: config.network === "sepolia" ? "Ethereum Sepolia" : "Hardhat Local",
networkName:
config.network === "sepolia" ? "Ethereum Sepolia" : "Hardhat Local",
networkStatus: "disconnected",
walletAddress: "",
};

View File

@@ -48,6 +48,37 @@ export interface BlockchainTransactionView {
explorerUrl: string | null;
}
/**
* Cryptographic certificate for a registered contract.
* Contains metadata, blockchain proof, and a digital signature.
* Can be downloaded and verified by third parties.
*/
export interface CryptographicCertificate {
// Document metadata
contractId: string;
contractTitle: string | null;
contractFileName: string;
documentHash: string;
// Blockchain proof
txHash: string;
blockNumber: number;
blockTimestamp: string; // ISO string
network: string;
contractAddress: string;
// Certificate metadata
certificateId: string; // Unique certificate identifier
issuedAt: string; // ISO string - when the certificate was generated
issuer: string; // Ethereum address that issued the certificate (server wallet)
// Digital signature (ECDSA)
signature: string; // Signed message in hex format (0x...)
// Version for future compatibility
version: "1.0";
}
/**
* Stats displayed at the top of the blockchain explorer page.
*/

View File

@@ -0,0 +1,402 @@
/**
* Certificate Service
*
* Generates cryptographically signed certificates for blockchain-registered contracts.
* These certificates can be downloaded and verified by third parties, proving that
* a contract was registered on-chain with specific metadata at a specific timestamp.
*
* Uses ECDSA (Elliptic Curve Digital Signature Algorithm) via ethers.js Wallet.
*/
import { ethers } from "ethers";
import { createHash } from "crypto";
import PDFDocument from "pdfkit";
import QRCode from "qrcode";
import type { CryptographicCertificate } from "./blockchain.types";
import { BlockchainService } from "./blockchain.service";
export class CertificateService {
/**
* Generate a digitally-signed certificate for a blockchain-registered contract.
*
* @param contractId - Contract ID
* @param contractTitle - Contract title
* @param contractFileName - Original file name
* @param documentHash - SHA-256 hash of the document
* @param txHash - Blockchain transaction hash
* @param blockNumber - Block number
* @param blockTimestamp - Block timestamp (ISO string)
* @param network - Blockchain network ('hardhat' | 'sepolia')
* @param contractAddress - Smart contract address
* @returns Digitally-signed certificate
*/
static async generateCertificate(
contractId: string,
contractTitle: string | null,
contractFileName: string,
documentHash: string,
txHash: string,
blockNumber: number,
blockTimestamp: string,
network: string,
contractAddress: string,
): Promise<CryptographicCertificate> {
if (!BlockchainService.isReadConfigured()) {
throw new Error("Blockchain not configured for certificate generation");
}
// Generate unique certificate ID
const certificateId = this.generateCertificateId(contractId);
// Get issuer wallet address
let issuerAddress = "";
try {
// Try to get the wallet address if blockchain write is configured
if (BlockchainService.isConfigured()) {
// We can't directly access the wallet from here, so we'll use the private key env var
const privateKey = process.env.BLOCKCHAIN_PRIVATE_KEY;
if (privateKey) {
const wallet = new ethers.Wallet(privateKey);
issuerAddress = wallet.address;
}
}
} catch {
// If wallet creation fails, use a placeholder
issuerAddress = "0x0000000000000000000000000000000000000000";
}
const issuedAt = new Date().toISOString();
// Prepare the data to sign
const certificateData = {
contractId,
contractTitle,
contractFileName,
documentHash,
txHash,
blockNumber,
blockTimestamp,
network,
contractAddress,
certificateId,
issuedAt,
issuer: issuerAddress,
version: "1.0",
};
// Create a message to sign (hash of the certificate data)
const messageToSign = this.createSignableMessage(certificateData);
const messageHash = ethers.hashMessage(messageToSign);
let signature = "";
try {
// Sign the message with the server wallet
if (BlockchainService.isConfigured()) {
const privateKey = process.env.BLOCKCHAIN_PRIVATE_KEY;
if (privateKey) {
const wallet = new ethers.Wallet(privateKey);
signature = await wallet.signMessage(messageToSign);
}
}
} catch (error) {
console.warn("Failed to sign certificate:", error);
signature = "0x"; // Return empty signature if signing fails
}
const certificate: CryptographicCertificate = {
contractId,
contractTitle,
contractFileName,
documentHash,
txHash,
blockNumber,
blockTimestamp,
network,
contractAddress,
certificateId,
issuedAt,
issuer: issuerAddress,
signature,
version: "1.0",
};
return certificate;
}
/**
* Generate a unique certificate ID based on contract ID and timestamp.
*/
private static generateCertificateId(contractId: string): string {
const timestamp = Date.now();
const combined = `${contractId}:${timestamp}`;
const hash = createHash("sha256").update(combined).digest("hex");
return `cert_${hash.slice(0, 16)}`;
}
/**
* Create a human-readable and machine-verifiable message to sign.
*/
private static createSignableMessage(data: any): string {
const lines = [
"═══════════════════════════════════════════",
"LexiChain Cryptographic Certificate",
"═══════════════════════════════════════════",
"",
`Certificate ID: ${data.certificateId}`,
`Contract: ${data.contractTitle || data.contractFileName}`,
`Document Hash: ${data.documentHash}`,
"",
"Blockchain Proof:",
` Transaction Hash: ${data.txHash}`,
` Block Number: ${data.blockNumber}`,
` Timestamp: ${data.blockTimestamp}`,
` Network: ${data.network}`,
` Contract Address: ${data.contractAddress}`,
"",
`Issued At: ${data.issuedAt}`,
`Issued By: ${data.issuer}`,
"",
"This certificate cryptographically proves that the above",
"document was registered on the blockchain at the specified",
"block and timestamp. The signature on this message can be",
"verified using the issuer's public key.",
"═══════════════════════════════════════════",
];
return lines.join("\n");
}
/**
* Verify a certificate's signature (useful for third parties).
* Returns true if the signature is valid and matches the issuer.
*/
static verifyCertificateSignature(
certificate: CryptographicCertificate,
): boolean {
try {
const messageToSign = this.createSignableMessage(certificate);
const recoveredAddress = ethers.verifyMessage(
messageToSign,
certificate.signature,
);
return (
recoveredAddress.toLowerCase() === certificate.issuer.toLowerCase()
);
} catch {
return false;
}
}
/**
* Generate a professional PDF certificate for the given certificate data.
*/
static async generateCertificatePdf(
certificate: CryptographicCertificate,
): Promise<Buffer> {
const qrPayload = JSON.stringify({
certificateId: certificate.certificateId,
contractId: certificate.contractId,
documentHash: certificate.documentHash,
txHash: certificate.txHash,
blockNumber: certificate.blockNumber,
blockTimestamp: certificate.blockTimestamp,
network: certificate.network,
contractAddress: certificate.contractAddress,
issuer: certificate.issuer,
issuedAt: certificate.issuedAt,
version: certificate.version,
});
const qrPng = await QRCode.toBuffer(qrPayload, {
width: 160,
margin: 1,
errorCorrectionLevel: "M",
});
return new Promise((resolve) => {
const doc = new PDFDocument({ size: "A4", margin: 56 });
const chunks: Buffer[] = [];
doc.on("data", (chunk) => chunks.push(chunk as Buffer));
doc.on("end", () => resolve(Buffer.concat(chunks)));
// Header
doc
.font("Helvetica-Bold")
.fontSize(20)
.fillColor("#111827")
.text("LexiChain Contract Authenticity Certificate", {
align: "center",
})
.moveDown(0.2);
doc
.font("Helvetica")
.fontSize(10)
.fillColor("#6B7280")
.text("Digitally signed proof of blockchain registration", {
align: "center",
})
.moveDown(1.2);
// Divider
doc
.moveTo(doc.page.margins.left, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.lineWidth(1)
.strokeColor("#E5E7EB")
.stroke()
.moveDown(0.8);
const pageLeft = doc.page.margins.left;
const pageRight = doc.page.width - doc.page.margins.right;
const gap = 24;
const qrSize = 120;
const rightX = pageRight - qrSize;
const leftWidth = pageRight - pageLeft - qrSize - gap;
const sectionTitle = (title: string) => {
doc
.font("Helvetica-Bold")
.fontSize(12)
.fillColor("#111827")
.text(title, pageLeft, doc.y)
.moveDown(0.4);
};
const writeFieldAt = (
x: number,
width: number,
label: string,
value: string,
) => {
doc
.font("Helvetica-Bold")
.fontSize(10)
.fillColor("#111827")
.text(`${label} `, x, doc.y, { continued: true, width });
doc.font("Helvetica").fillColor("#111827").text(value, { width });
doc.moveDown(0.3);
};
// Summary + QR
const summaryStartY = doc.y + 6;
doc.image(qrPng, rightX, summaryStartY, {
width: qrSize,
height: qrSize,
});
doc
.font("Helvetica")
.fontSize(8)
.fillColor("#6B7280")
.text("Verification QR", rightX, summaryStartY + qrSize + 4, {
width: qrSize,
align: "center",
});
doc.y = summaryStartY;
doc
.font("Helvetica-Bold")
.fontSize(12)
.fillColor("#111827")
.text("Certificate Summary", pageLeft, doc.y, { width: leftWidth })
.moveDown(0.4);
writeFieldAt(
pageLeft,
leftWidth,
"Certificate ID:",
certificate.certificateId,
);
writeFieldAt(
pageLeft,
leftWidth,
"Contract:",
certificate.contractTitle || certificate.contractFileName,
);
writeFieldAt(pageLeft, leftWidth, "Issued At:", certificate.issuedAt);
writeFieldAt(pageLeft, leftWidth, "Network:", certificate.network);
doc.y = Math.max(doc.y, summaryStartY + qrSize + 24);
doc.moveDown(0.4);
// Document Proof
sectionTitle("Document Proof");
doc.font("Helvetica-Bold").fontSize(10).text("Document Hash:");
doc
.font("Courier")
.fontSize(9)
.fillColor("#111827")
.text(certificate.documentHash, { lineGap: 2 })
.moveDown(0.6);
// Blockchain Proof
sectionTitle("Blockchain Proof");
doc.font("Helvetica-Bold").fontSize(10).text("Transaction Hash:");
doc
.font("Courier")
.fontSize(9)
.fillColor("#111827")
.text(certificate.txHash, { lineGap: 2 })
.moveDown(0.4);
writeFieldAt(
pageLeft,
pageRight - pageLeft,
"Block Number:",
String(certificate.blockNumber),
);
writeFieldAt(
pageLeft,
pageRight - pageLeft,
"Block Timestamp:",
certificate.blockTimestamp,
);
writeFieldAt(
pageLeft,
pageRight - pageLeft,
"Contract Address:",
certificate.contractAddress,
);
// Issuer and Signature
doc.moveDown(0.2);
sectionTitle("Issuer and Signature");
writeFieldAt(
pageLeft,
pageRight - pageLeft,
"Issued By:",
certificate.issuer,
);
doc.font("Helvetica-Bold").fontSize(10).text("Digital Signature:");
doc
.font("Courier")
.fontSize(8.5)
.fillColor("#111827")
.text(certificate.signature || "Signature not available", {
lineGap: 2,
})
.moveDown(0.6);
doc
.font("Helvetica")
.fontSize(9)
.fillColor("#374151")
.text(
"This certificate verifies that the document referenced above was registered on the blockchain and can be independently verified using the issuer address and signature.",
{ align: "left" },
)
.moveDown(0.4);
doc
.font("Helvetica")
.fontSize(8)
.fillColor("#6B7280")
.text("LexiChain - Confidential and tamper-evident certification.", {
align: "center",
});
doc.end();
});
}
}

View File

@@ -31,6 +31,16 @@ interface ContractAnalysisEmailInput {
blockchain?: BlockchainEmailData | null;
}
interface ContractDeadlineEmailInput {
to: string;
userDisplayName?: string | null;
contractId: string;
contractTitle: string | null;
contractProvider: string | null;
contractEndDate: Date;
daysUntilExpiration: number;
}
let transporter: nodemailer.Transporter | null = null;
let transportMode: "smtp" | "ethereal" | null = null;
let hasWarnedMissingEmailConfig = false;
@@ -134,6 +144,17 @@ const formatContractLink = (contractId: string): string | null => {
return `${baseUrl.replace(/\/$/, "")}/contacts?contract=${contractId}`;
};
const getBaseUrl = (): string | null => {
const baseUrl =
process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.APP_URL?.trim();
return baseUrl ? baseUrl.replace(/\/$/, "") : null;
};
const getLogoUrl = (): string | null => {
const baseUrl = getBaseUrl();
return baseUrl ? `${baseUrl}/LexiChain.png` : null;
};
export class EmailService {
static async sendContractAnalysisCompletedEmail(
input: ContractAnalysisEmailInput,
@@ -178,6 +199,7 @@ export class EmailService {
input.blueprint.premiumCurrency,
);
const contractUrl = formatContractLink(input.contractId);
const logoUrl = getLogoUrl();
const blockchainStatus = input.blockchain
? "Registered"
: "Not registered (blockchain unavailable or skipped)";
@@ -185,7 +207,7 @@ export class EmailService {
const textBody = [
`Hello ${recipientName},`,
"",
"Your contract analysis is complete.",
"Your LexiChain contract intelligence report is ready.",
"",
"Blueprint:",
`- Contract title: ${input.contractTitle}`,
@@ -197,7 +219,7 @@ export class EmailService {
`- End date: ${formatDateValue(input.blueprint.endDate)}`,
`- Premium: ${premiumLabel}`,
"",
"Summary:",
"Executive summary:",
input.blueprint.summary,
"",
"Blockchain proof:",
@@ -212,46 +234,97 @@ export class EmailService {
"",
contractUrl ? `Open in app: ${contractUrl}` : "",
"",
"Keep this email for your records.",
"Thank you for trusting LexiChain for secure contract governance.",
]
.filter(Boolean)
.join("\n");
const htmlBody = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #0f172a;">
<h2 style="margin-bottom: 12px;">Contract Analysis Completed</h2>
<p>Hello ${recipientName},</p>
<p>Your contract analysis has been completed successfully.</p>
<div style="margin:0;padding:0;background:#0f172a;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#0f172a;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="background:#ffffff;border-radius:20px;overflow:hidden;font-family:Arial, sans-serif;color:#0f172a;">
<tr>
<td style="padding:24px 28px;background:linear-gradient(135deg,#0f172a 0%,#1d4ed8 100%);color:#ffffff;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="left" style="vertical-align:middle;">
${logoUrl ? `<img src="${logoUrl}" alt="LexiChain" width="140" style="display:block;border-radius:10px;" />` : '<strong style="font-size:20px;">LexiChain</strong>'}
</td>
<td align="right" style="vertical-align:middle;font-size:12px;letter-spacing:1.4px;text-transform:uppercase;opacity:0.85;">
Contract Intelligence
</td>
</tr>
</table>
<h1 style="margin:20px 0 6px;font-size:26px;">Your contract insight report is ready</h1>
<p style="margin:0;font-size:14px;opacity:0.9;">Clear, verified, and ready for action.</p>
</td>
</tr>
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blueprint</h3>
<ul>
<li><strong>Contract title:</strong> ${input.contractTitle}</li>
<li><strong>Original file:</strong> ${input.contractFileName}</li>
<li><strong>Type:</strong> ${input.blueprint.type}</li>
<li><strong>Provider:</strong> ${input.blueprint.provider ?? "N/A"}</li>
<li><strong>Policy number:</strong> ${input.blueprint.policyNumber ?? "N/A"}</li>
<li><strong>Start date:</strong> ${formatDateValue(input.blueprint.startDate)}</li>
<li><strong>End date:</strong> ${formatDateValue(input.blueprint.endDate)}</li>
<li><strong>Premium:</strong> ${premiumLabel}</li>
</ul>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 16px;font-size:14px;">Hello ${recipientName},</p>
<p style="margin:0 0 20px;font-size:14px;color:#334155;">
Your LexiChain analysis is complete. Below is the executive blueprint and proof trace.
</p>
<h3 style="margin-top: 24px; margin-bottom: 8px;">Summary</h3>
<p>${input.blueprint.summary.replace(/\n/g, "<br />")}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:16px;">
<tr>
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;">Blueprint Summary</td>
</tr>
<tr>
<td style="font-size:13px;color:#475569;">
<div><strong>Contract:</strong> ${input.contractTitle}</div>
<div><strong>File:</strong> ${input.contractFileName}</div>
<div><strong>Type:</strong> ${input.blueprint.type}</div>
<div><strong>Provider:</strong> ${input.blueprint.provider ?? "N/A"}</div>
<div><strong>Policy #:</strong> ${input.blueprint.policyNumber ?? "N/A"}</div>
<div><strong>Start:</strong> ${formatDateValue(input.blueprint.startDate)}</div>
<div><strong>End:</strong> ${formatDateValue(input.blueprint.endDate)}</div>
<div><strong>Premium:</strong> ${premiumLabel}</div>
</td>
</tr>
</table>
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blockchain Proof</h3>
<ul>
<li><strong>Status:</strong> ${blockchainStatus}</li>
<li><strong>Document hash:</strong> ${input.blockchain?.documentHash ?? "N/A"}</li>
<li><strong>Transaction hash:</strong> ${input.blockchain?.txHash ?? "N/A"}</li>
<li><strong>Block number:</strong> ${input.blockchain?.blockNumber ?? "N/A"}</li>
<li><strong>Block time:</strong> ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}</li>
<li><strong>Network:</strong> ${input.blockchain?.network ?? "N/A"}</li>
<li><strong>Contract address:</strong> ${input.blockchain?.contractAddress ?? "N/A"}</li>
<li><strong>Explorer URL:</strong> ${input.blockchain?.explorerUrl ? `<a href="${input.blockchain.explorerUrl}" target="_blank" rel="noopener noreferrer">Open transaction</a>` : "N/A"}</li>
</ul>
<div style="margin:18px 0 8px;font-size:13px;font-weight:bold;">Executive Summary</div>
<p style="margin:0 0 18px;font-size:13px;color:#475569;line-height:1.6;">${input.blueprint.summary.replace(/\n/g, "<br />")}</p>
${contractUrl ? `<p><a href="${contractUrl}">Open this contract in your dashboard</a></p>` : ""}
<p style="margin-top: 24px; font-size: 12px; color: #475569;">Keep this email for your records.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#eef2ff;border:1px solid #c7d2fe;border-radius:14px;padding:16px;">
<tr>
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;color:#1e3a8a;">Blockchain Proof</td>
</tr>
<tr>
<td style="font-size:12px;color:#1e3a8a;line-height:1.6;">
<div><strong>Status:</strong> ${blockchainStatus}</div>
<div><strong>Document hash:</strong> ${input.blockchain?.documentHash ?? "N/A"}</div>
<div><strong>Transaction hash:</strong> ${input.blockchain?.txHash ?? "N/A"}</div>
<div><strong>Block number:</strong> ${input.blockchain?.blockNumber ?? "N/A"}</div>
<div><strong>Block time:</strong> ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}</div>
<div><strong>Network:</strong> ${input.blockchain?.network ?? "N/A"}</div>
<div><strong>Contract address:</strong> ${input.blockchain?.contractAddress ?? "N/A"}</div>
<div><strong>Explorer:</strong> ${input.blockchain?.explorerUrl ? `<a href="${input.blockchain.explorerUrl}" target="_blank" rel="noopener noreferrer" style="color:#1d4ed8;text-decoration:none;">Open transaction</a>` : "N/A"}</div>
</td>
</tr>
</table>
${
contractUrl
? `
<div style="margin-top:22px;">
<a href="${contractUrl}" style="display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;font-weight:bold;font-size:13px;">Open in LexiChain</a>
</div>
`
: ""
}
<p style="margin:20px 0 0;font-size:12px;color:#94a3b8;">Precision you can audit. Trust you can prove.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`;
@@ -259,11 +332,14 @@ export class EmailService {
from,
to: input.to,
subject: `Contract analyzed: ${input.contractTitle}`,
text: textBody,
html: htmlBody,
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
const previewUrl = nodemailer.getTestMessageUrl(info);
const previewUrl =
(nodemailer.getTestMessageUrl(info) as string | false) || null;
if (previewUrl) {
console.log(`📨 Ethereal preview URL: ${previewUrl}`);
}
@@ -277,4 +353,160 @@ export class EmailService {
};
}
}
static async sendContractDeadlineReminderEmail(
input: ContractDeadlineEmailInput,
): Promise<{
success: boolean;
error?: string;
skipped?: boolean;
previewUrl?: string | null;
}> {
try {
const mailer = await getTransporter();
if (!mailer) {
return {
success: false,
skipped: true,
error: "Email service not configured",
};
}
const from =
process.env.MAIL_FROM?.trim() ||
process.env.EMAIL_USER?.trim() ||
(transportMode === "ethereal"
? "LexiChain <no-reply@ethereal.email>"
: "");
if (!from) {
warnMissingEmailConfigOnce();
return { success: false, skipped: true, error: "MAIL_FROM is missing" };
}
if (!input.to?.trim()) {
return {
success: false,
skipped: true,
error: "Recipient email is missing",
};
}
const recipientName = input.userDisplayName || "there";
const contractUrl = formatContractLink(input.contractId);
const logoUrl = getLogoUrl();
const endDate = input.contractEndDate.toLocaleDateString();
const urgencyLabel =
input.daysUntilExpiration <= 7
? "Urgent"
: input.daysUntilExpiration <= 14
? "High"
: "Planned";
const textBody = [
`Hello ${recipientName},`,
"",
`Your contract deadline is approaching in ${input.daysUntilExpiration} days.`,
"",
`Contract: ${input.contractTitle ?? "Untitled contract"}`,
`Provider: ${input.contractProvider ?? "N/A"}`,
`End date: ${endDate}`,
`Urgency: ${urgencyLabel}`,
"",
contractUrl ? `Open in app: ${contractUrl}` : "",
"",
"Please review the contract and schedule renewal if needed.",
]
.filter(Boolean)
.join("\n");
const htmlBody = `
<div style="margin:0;padding:0;background:#0f172a;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#0f172a;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="background:#ffffff;border-radius:20px;overflow:hidden;font-family:Arial, sans-serif;color:#0f172a;">
<tr>
<td style="padding:22px 28px;background:linear-gradient(135deg,#0f172a 0%,#2563eb 100%);color:#ffffff;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="left" style="vertical-align:middle;">
${logoUrl ? `<img src="${logoUrl}" alt="LexiChain" width="130" style="display:block;border-radius:10px;" />` : '<strong style="font-size:18px;">LexiChain</strong>'}
</td>
<td align="right" style="vertical-align:middle;font-size:12px;letter-spacing:1.4px;text-transform:uppercase;opacity:0.85;">
Deadline Alert
</td>
</tr>
</table>
<h1 style="margin:18px 0 6px;font-size:24px;">Contract renewal reminder</h1>
<p style="margin:0;font-size:14px;opacity:0.9;">Stay ahead of critical dates.</p>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 14px;font-size:14px;">Hello ${recipientName},</p>
<p style="margin:0 0 18px;font-size:14px;color:#334155;">
Your contract deadline is approaching in <strong>${input.daysUntilExpiration} days</strong>.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:16px;">
<tr>
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;">Deadline Summary</td>
</tr>
<tr>
<td style="font-size:13px;color:#475569;line-height:1.6;">
<div><strong>Contract:</strong> ${input.contractTitle ?? "Untitled contract"}</div>
<div><strong>Provider:</strong> ${input.contractProvider ?? "N/A"}</div>
<div><strong>End date:</strong> ${endDate}</div>
<div><strong>Urgency:</strong> ${urgencyLabel}</div>
</td>
</tr>
</table>
${
contractUrl
? `
<div style="margin-top:20px;">
<a href="${contractUrl}" style="display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;font-weight:bold;font-size:13px;">Review in LexiChain</a>
</div>
`
: ""
}
<p style="margin:18px 0 0;font-size:12px;color:#94a3b8;">Plan renewals early to avoid coverage gaps.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`;
const info = await mailer.sendMail({
from,
to: input.to,
subject: `Contract deadline in ${input.daysUntilExpiration} days`,
html: htmlBody,
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
const previewUrl =
(nodemailer.getTestMessageUrl(info) as string | false) || null;
if (previewUrl) {
console.log(`📨 Ethereal preview URL: ${previewUrl}`);
}
return { success: true, previewUrl };
} catch (error) {
console.error("Failed to send deadline reminder email:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown email error",
};
}
}
}

View File

@@ -13,6 +13,7 @@
*/
import { prisma } from "@/lib/db/prisma";
import { EmailService } from "@/lib/services/email.service";
let hasWarnedMissingNotificationTable = false;
@@ -547,9 +548,9 @@ export class NotificationService {
* Checks for upcoming contract renewals/expirations and creates notifications
*
* Scans all contracts for a user and creates DEADLINE notifications for:
* - 30 days before expiration (CRITICAL)
* - 15 days before expiration (WARNING)
* - 7 days before expiration (URGENT)
* - 30 days before expiration
* - 14 days before expiration
* - 7 days before expiration
*
* @param userId - The user's ID
* @returns Promise with count of created notifications
@@ -557,7 +558,7 @@ export class NotificationService {
* Steps:
* 1. Query all COMPLETED contracts with endDate for the user
* 2. Calculate days until expiration
* 3. Create notification if contract expiring in 30, 15, or 7 days
* 3. Create notification if contract expiring in 30, 14, or 7 days
* 4. Check for existing notification to avoid duplicates
* 5. Return summary of created notifications
*
@@ -573,6 +574,15 @@ export class NotificationService {
const today = new Date();
today.setHours(0, 0, 0, 0);
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
email: true,
firstName: true,
lastName: true,
},
});
// Query all contracts with endDate for this user
const contracts = await prisma.contract.findMany({
where: {
@@ -610,12 +620,12 @@ export class NotificationService {
if (daysUntilExpiration === 7) {
shouldNotify = true;
level = "URGENT";
} else if (daysUntilExpiration === 15) {
} else if (daysUntilExpiration === 14) {
shouldNotify = true;
level = "WARNING";
} else if (daysUntilExpiration === 30) {
shouldNotify = true;
level = "CRITICAL";
level = "NOTICE";
}
if (shouldNotify) {
@@ -634,18 +644,21 @@ export class NotificationService {
// Only create if not already notified today
if (!existingNotification) {
const notificationTitle =
level === "CRITICAL"
? `🔴 Contract Expiring in 30 Days`
level === "NOTICE"
? "Contract renewal reminder (30 days)"
: level === "WARNING"
? `🟠 Contract Expiring in 15 Days`
: `🟡 Contract Expiring in 7 Days`;
? "Contract renewal window (14 days)"
: "Contract deadline in 7 days";
const providerLabel = contract.provider || "your provider";
const contractLabel = contract.title || "your contract";
const notificationMessage =
level === "CRITICAL"
? `${contract.title} from ${contract.provider} will expire on ${contractEnd.toLocaleDateString()}. Time to renew!`
level === "NOTICE"
? `${contractLabel} from ${providerLabel} will expire on ${contractEnd.toLocaleDateString()}. Plan your renewal.`
: level === "WARNING"
? `${contract.title} from ${contract.provider} expires in 15 days. Consider scheduling renewal.`
: `${contract.title} from ${contract.provider} expires in 7 days. Renew now!`;
? `${contractLabel} from ${providerLabel} expires in 14 days. Please review renewal steps.`
: `${contractLabel} from ${providerLabel} expires in 7 days. Action is required.`;
const result = await this.create({
userId,
@@ -654,7 +667,7 @@ export class NotificationService {
message: notificationMessage,
contractId: contract.id,
actionType: `RENEWAL_${level}`,
icon: level === "CRITICAL" ? "AlertCircle" : "AlertTriangle",
icon: level === "URGENT" ? "AlertCircle" : "AlertTriangle",
expiresIn: 24 * 60 * 60 * 1000, // 24 hours
actionData: {
level,
@@ -667,6 +680,29 @@ export class NotificationService {
if (result.success) {
createdNotifications.push(contract.id);
if (user?.email) {
try {
await EmailService.sendContractDeadlineReminderEmail({
to: user.email,
userDisplayName:
`${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() ||
null,
contractId: contract.id,
contractTitle: contract.title,
contractProvider: contract.provider,
contractEndDate: contractEnd,
daysUntilExpiration,
});
} catch (emailError) {
console.warn(
"Deadline email failed:",
emailError instanceof Error
? emailError.message
: emailError,
);
}
}
}
}
}