Readme
This commit is contained in:
@@ -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: "",
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
402
lib/services/certificate.service.ts
Normal file
402
lib/services/certificate.service.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user