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

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
node_modules
.next
.git
.gitignore
Dockerfile
npm-debug.log
yarn-error.log
pnpm-debug.log
.env
.env.*
blockchain/artifacts
blockchain/cache
blockchain/typechain-types
blockchain/node_modules

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json* ./
RUN npm ci
FROM base AS builder
RUN apk add --no-cache libc6-compat
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
RUN apk add --no-cache libc6-compat
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

247
README.md
View File

@@ -1,110 +1,215 @@
# 🔗 LexiChain: Professional BFSI Document Intelligence Platform # LexiChain
> **Status**: Production-Ready PFE Project <p align="center">
> **Target Audience**: Banking, Financial Services, and Insurance (BFSI) Institutions <img src="public/LexiChain.png" alt="LexiChain logo" width="180" />
> **Key Innovation**: Hybrid integration of Generative AI (Gemini) and Ethereum Blockchain (DocumentRegistry) </p>
--- <p align="center">
BFSI document intelligence platform that combines AI-assisted analysis with blockchain-backed verification.
</p>
## 🏛️ 1. Project Vision & Mission <p align="center">
<img src="https://img.shields.io/badge/Next.js-16-000000?logo=nextdotjs&logoColor=white" alt="Next.js badge" />
<img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React badge" />
<img src="https://img.shields.io/badge/Hardhat-Smart%20Contracts-FFF100?logo=ethereum&logoColor=black" alt="Hardhat badge" />
<img src="https://img.shields.io/badge/Prisma-Database%20Layer-2D3748?logo=prisma&logoColor=white" alt="Prisma badge" />
<img src="https://img.shields.io/badge/Clerk-Auth%20Ready-3B82F6?logo=clerk&logoColor=white" alt="Clerk badge" />
</p>
**LexiChain** is a next-generation platform designed to bridge the gap between complex legal documentation and user-centric transparency. In the traditional BFSI sector, contracts are often "black boxes"—static PDFs that are hard to understand and easy to misplace. LexiChain is a team-ready platform for uploading documents, extracting important information, verifying integrity, and tracking records through a polished enterprise interface. It combines a modern Next.js frontend, a Solidity/Hardhat blockchain module, PostgreSQL with Prisma, and Clerk authentication.
LexiChain transforms these static documents into **dynamic, searchable, and cryptographically secured assets**. Our mission is to automate document analysis while providing an immutable "Digital Notary" service that guarantees trust between financial institutions and their clients. ## Overview
--- LexiChain is designed for banking, financial services, and insurance workflows where documents must be understandable, traceable, and trustworthy.
## 📉 2. Market Problem & Opportunity It addresses four core needs:
The project addresses several critical "pain points" in the current financial landscape: - converting dense documents into structured, reviewable insights
- reducing repetitive validation and manual review work
- preserving tamper-evident proof for uploaded files
- giving end users a clearer and more professional contract experience
1. **The Transparency Gap**: Clients often sign contracts without understanding specific exclusion clauses or renewal dates. ## Product Highlights
2. **Operational Friction**: Insurance agents spend thousands of hours manually checking PDFs for compliance and signature presence.
3. **The Integrity Risk**: In legal disputes, proving that a specific version of a document was the one actually signed can be difficult and expensive.
4. **Information Overload**: Users are overwhelmed by the volume of fine print in modern banking.
--- - AI document analysis powered by Gemini
- OCR-based ingestion for digital and scanned documents
- automated extraction of key contract details
- chat-style retrieval over document content
- blockchain proof-of-existence for uploaded files
- document verification against on-chain hashes
- secure authentication and per-user access control
- dashboard views for contracts, contacts, and claims workflows
- responsive UI with a dark, premium visual style
## 🚀 3. Core Feature Deep-Dive ## Visual Preview
### 🧠 A. The AI "Analyst" Module (Intelligence Layer) The screenshots in [public/screens](public/screens) are intentionally included to make the README feel like a product page and to help new contributors understand the interface quickly.
LexiChain uses **Google Gemini 2.0 Flash** to act as a virtual legal analyst. | Landing page | Dashboard |
| ------------------------------------------------ | ----------------------------------------------- |
| ![Landing page](public/screens/landing_page.png) | ![Dashboard](public/screens/dashboard_dark.png) |
- **Smart Ingestion**: Uses high-fidelity OCR to read digital PDFs and scanned images. | Login | Register |
- **Automated Extraction**: Identifies 15+ key data points (Amount, Interest Rate, Parties, Expiration Dates, Clauses) instantly. | ---------------------------------------------- | ---------------------------------------------------- |
- **RAG (Retrieval-Augmented Generation)**: The most advanced part of the AI. It breaks the contract into "Semantic Chunks" and stores them in a vector index. This allows the user to **Chat with their Contract** in natural language. | ![Login screen](public/screens/login_dark.png) | ![Register screen](public/screens/register_dark.png) |
- **Intelligent Validation**: The AI automatically flags missing signatures, inconsistent dates, or high-risk clauses before the document is finalized.
### ⛓️ B. The Blockchain "Notary" Module (Security Layer) | Contracts |
| ---------------------------------------------------- |
| ![Contracts view](public/screens/contracts_dark.png) |
LexiChain uses a private/public hybrid blockchain strategy to ensure **Non-Repudiation**. ## Architecture
- **Proof of Existence (PoE)**: We generate a SHA-256 hash (digital fingerprint) for every file. This hash is sent to a **Solidity Smart Contract** on the Ethereum network. LexiChain follows a practical feature-oriented structure:
- **Immutable Timestamping**: The blockchain records exactly _when_ the document was uploaded. This cannot be changed by any administrator, providing a "Golden Record."
- **Metadata Leakage Prevention**: We only store the **Hash** on-chain. No personal data (names, amounts) ever touches the public blockchain, ensuring 100% GDPR compliance.
- **The Explorer**: A built-in "Verification Panel" that allows any auditor to verify a file's integrity by comparing its current hash with the on-chain record.
--- - [app](app) contains the Next.js app router, layouts, and pages
- [components](components) contains shared UI and layout building blocks
- [features](features) contains feature-level business logic
- [hooks](hooks) contains reusable client hooks
- [lib](lib) contains shared utilities and helpers
- [prisma](prisma) contains database schema files
- [blockchain](blockchain) contains the Hardhat project and smart contracts
- [public/screens](public/screens) contains screenshots and visual assets
## 🏗️ 4. Technical Architecture The solution is split across three main concerns:
### **Architecture Pattern: Feature-Sliced Design (FSD)** | Layer | Purpose |
| ---------- | ----------------------------------------------------- |
| Frontend | UI, dashboards, forms, and user interactions |
| Backend | Server actions and API routes for business workflows |
| Blockchain | Smart contract registration and document verification |
The project follows **FSD principles**, which is a modern architectural pattern for scaling large applications. ## Tech Stack
- **Layers**: App, Pages, Features, Entities, Shared. | Area | Stack |
- **Benefits**: Decouples the Blockchain logic from the AI logic, making the system highly maintainable and ready for enterprise scaling. | -------------- | ------------------------------------ |
| Frontend | Next.js 16, React 19, Tailwind CSS |
| Backend | Next.js server actions, API routes |
| AI | Google Gemini |
| Blockchain | Solidity, Hardhat, Ethers.js |
| Database | PostgreSQL, Prisma |
| Authentication | Clerk |
| UI Components | Radix UI, shadcn/ui-style primitives |
### **The Stack** ## Prerequisites
| Layer | Technology | Rationale | - Node.js 20 or later
| :------------- | :----------------------- | :-------------------------------------------------------------------------------------------------- | - npm
| **Frontend** | Next.js 15 (React) | High performance, SEO-friendly, and supports React Server Components. | - PostgreSQL database
| **Backend** | Server Actions (Next.js) | Allows for secure, server-side blockchain signing and API communication without a separate backend. - a local or remote blockchain endpoint for development and deployment
| **LLM** | Gemini | Unbeatable speed and context window size for long legal documents. | - environment variables for Clerk, Gemini, database, and blockchain access
| **Blockchain** | Solidity / Hardhat | Ethereum-compatible smart contracts for industry-standard security. |
| **Database** | PostgreSQL + Prisma | Robust relational storage for user data and contract metadata. |
| **Identity** | Clerk | Enterprise-grade security for user authentication and session management. |
--- ## Environment Variables
## 🔒 5. Security & Privacy Philosophy The exact values depend on your deployment target, but the project expects environment configuration for the following areas:
LexiChain is built with a **"Security by Design"** approach: | Area | Typical Variables |
| -------------- | ---------------------------------------------- |
| App | `NODE_ENV`, app URL settings |
| Database | `DATABASE_URL` |
| Authentication | Clerk publishable and secret keys |
| AI | Gemini API key or equivalent model credentials |
| Blockchain | RPC URL, contract address, private key |
- **Hashing vs. Storage**: We never store the actual document on the blockchain. The blockchain only holds the "Proof," while the "Content" remains in encrypted cloud storage. Keep secrets in environment variables and never commit them to the repository.
- **Server-Side Signing**: Users don't need a crypto wallet (MetaMask). Our backend acts as a **Trusted Custodian**, signing transactions with a secure private key hidden in environment variables.
- **Authentication Hooks**: Access to contracts is strictly controlled via Clerk Auth, ensuring users only see their own data.
--- ## Installation
## 🎨 6. UX/UI & Aesthetics Install the root application dependencies first:
The application features a **"Premium Glassmorphism"** design system. ```bash
npm install
```
- **Why?**: In BFSI, the interface must convey **Trust, Modernity, and Clarity**. Then install the blockchain workspace dependencies:
- **Design Tokens**: We use vibrant gradients, subtle blurs, and micro-animations to make the complex task of contract management feel light and intuitive.
- **Theme Awareness**: The UI is optimized for both Light and Dark modes, adapting to the professional environment of the user.
--- ```bash
cd blockchain
npm install
```
## 🌟 7. Why this is a 10/10 PFE Project ## Local Development
LexiChain isn't just a simple web app; it is a **Multidisciplinary Innovation**: The recommended development command starts the app together with the local chain workflow:
1. **AI Innovation**: Moving beyond simple text extraction to a conversational RAG system. ```bash
2. **Blockchain Innovation**: Implementing a production-ready Document Registry that solves real-world legal issues. npm run dev
3. **Architectural Integrity**: Using FSD and Clean Code principles usually found in senior-level software engineering. ```
4. **Market Readiness**: The solution is directly applicable to banks and insurance companies looking to digitalize their workflow.
--- This runs the bootstrap script defined in the root package and is the best option when you want the frontend and blockchain pieces aligned locally.
## 📚 8. Glossary for NotebookLM If you only need the Next.js app without the chain bootstrap, run:
- **BFSI**: Banking, Financial Services, and Insurance. ```bash
- **RAG (Retrieval-Augmented Generation)**: A technique that allows AI to answer questions based _only_ on the provided documents, preventing "hallucinations." npm run dev:next
- **Smart Contract**: A programmable contract that executes automatically when conditions are met. ```
- **SHA-256**: A one-way cryptographic function. If you change 1 bit of a file, the entire hash changes.
- **Hardhat**: A professional development environment for Ethereum software. ## Scripts
- **Sepolia**: The public test network used to simulate the real Ethereum blockchain.
### Root workspace
| Command | Description |
| ------------------------ | ------------------------------------------- |
| `npm run dev` | Starts the combined development workflow |
| `npm run dev:with-chain` | Alias for the combined development workflow |
| `npm run dev:next` | Runs only the Next.js dev server |
| `npm run build` | Builds the production application |
| `npm run start` | Starts the production server |
| `npm run lint` | Runs ESLint |
### Blockchain workspace
| Command | Description |
| ------------------------ | ------------------------------------ |
| `npm run compile` | Compiles the smart contracts |
| `npm run test` | Runs the Hardhat test suite |
| `npm run node` | Starts a local Hardhat node |
| `npm run deploy:local` | Deploys to the local Hardhat network |
| `npm run deploy:sepolia` | Deploys to Sepolia |
## Deployment
This is the clean production path for option 3 in the README.
```bash
npm run build
npm run start
```
Before deploying, verify:
- environment variables are set correctly
- the database is reachable
- the blockchain contract address and RPC settings are valid
- the code passes linting and any required tests
## Team Notes
These are the implementation details future contributors should know before extending the app:
- Contract records store the internal application user id, not the Clerk user id; resolve the Clerk user first before querying contract data.
- The upload flow triggers analysis automatically after saving a contract, so the UI should show analysis-in-progress feedback immediately.
- Dashboard and contacts routes live under `/dashboard` and `/contacts`.
- Trend charts should be aggregated by day with zero-filled windows so 30-day views stay accurate.
- Q&A over contracts uses Gemini embeddings and an in-memory contract vector cache.
- Claim status updates are intentionally restricted; the UI should use the allowed next statuses for each claim instead of a global list.
## Contribution Guidelines
If you are continuing this project as a team, keep the following practices in mind:
- make focused changes and avoid mixing unrelated refactors with feature work
- keep environment-dependent values out of source control
- prefer the existing feature structure when adding new UI or business logic
- update screenshots when user-facing screens change significantly
- document workflow changes in the README or the relevant docs file
## Troubleshooting
- If the app does not start, verify your Node.js version and reinstall dependencies.
- If blockchain features fail, confirm the local node is running and the contract address points to a deployed contract.
- If authentication fails, recheck the Clerk environment variables and callback configuration.
- If database access fails, validate the Prisma connection string and database availability.
## Summary
LexiChain is a BFSI document intelligence platform built for contract review, verification, and operational clarity. The codebase is structured to be maintainable by a team, with a clear split between app, blockchain, data, and shared UI concerns.

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useEffect, useState, useCallback } from "react"; import React, { useEffect, useState, useCallback } from "react";
import { motion } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { import {
Link2, Link2,
Shield, Shield,
@@ -18,6 +18,13 @@ import {
Check, Check,
AlertCircle, AlertCircle,
Upload, Upload,
Zap,
Fingerprint,
Radio,
ChevronRight,
Terminal,
Lock,
Globe,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -25,6 +32,7 @@ import {
getBlockchainStats, getBlockchainStats,
verifyDocumentHashOnBlockchain, verifyDocumentHashOnBlockchain,
registerContractOnBlockchain, registerContractOnBlockchain,
generateContractCertificate,
} from "@/features/blockchain/api/blockchain.action"; } from "@/features/blockchain/api/blockchain.action";
import { getContracts } from "@/features/contracts/api/contract.action"; import { getContracts } from "@/features/contracts/api/contract.action";
import type { import type {
@@ -34,11 +42,13 @@ import type {
import { toast } from "sonner"; import { toast } from "sonner";
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Blockchain Explorer Page // Blockchain Explorer — 2026 Edition
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
export default function BlockchainExplorerPage() { export default function BlockchainExplorerPage() {
const [transactions, setTransactions] = useState<BlockchainTransactionView[]>([]); const [transactions, setTransactions] = useState<BlockchainTransactionView[]>(
[],
);
const [stats, setStats] = useState<BlockchainStats | null>(null); const [stats, setStats] = useState<BlockchainStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [verifyHash, setVerifyHash] = useState(""); const [verifyHash, setVerifyHash] = useState("");
@@ -53,6 +63,12 @@ export default function BlockchainExplorerPage() {
Array<{ id: string; title: string | null; fileName: string }> Array<{ id: string; title: string | null; fileName: string }>
>([]); >([]);
const [registeringId, setRegisteringId] = useState<string | null>(null); const [registeringId, setRegisteringId] = useState<string | null>(null);
const [downloadingCertificateId, setDownloadingCertificateId] = useState<
string | null
>(null);
const [activeTab, setActiveTab] = useState<"all" | "verified" | "pending">(
"all",
);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -70,15 +86,14 @@ export default function BlockchainExplorerPage() {
setStats(statsResult.stats); setStats(statsResult.stats);
} }
// Find contracts not yet on blockchain
if (contractsResult.success && contractsResult.contracts) { if (contractsResult.success && contractsResult.contracts) {
const registered = new Set( const registered = new Set(
txResult.transactions?.map((tx) => tx.contractId) ?? [] txResult.transactions?.map((tx) => tx.contractId) ?? [],
); );
const unregistered = contractsResult.contracts const unregistered = contractsResult.contracts
.filter( .filter(
(c: { id: string; txHash?: string | null; status: string }) => (c: { id: string; txHash?: string | null; status: string }) =>
!c.txHash && !registered.has(c.id) && c.status === "COMPLETED" !c.txHash && !registered.has(c.id) && c.status === "COMPLETED",
) )
.map((c: { id: string; title: string | null; fileName: string }) => ({ .map((c: { id: string; title: string | null; fileName: string }) => ({
id: c.id, id: c.id,
@@ -133,6 +148,42 @@ export default function BlockchainExplorerPage() {
} }
}; };
const handleDownloadCertificate = async (
contractId: string,
contractTitle: string,
) => {
setDownloadingCertificateId(contractId);
try {
const result = await generateContractCertificate(contractId);
if (result.success && result.certificatePdfBase64) {
const base64 = result.certificatePdfBase64;
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download =
result.certificateFileName ||
`certificate-${contractTitle || contractId}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Certificate downloaded successfully!");
} else {
toast.error(result.error || "Failed to generate certificate");
}
} catch {
toast.error("Failed to download certificate");
} finally {
setDownloadingCertificateId(null);
}
};
const copyToClipboard = (text: string) => { const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
setCopiedTx(text); setCopiedTx(text);
@@ -148,178 +199,324 @@ export default function BlockchainExplorerPage() {
return `${hash.slice(0, chars + 2)}...${hash.slice(-chars)}`; return `${hash.slice(0, chars + 2)}...${hash.slice(-chars)}`;
}; };
const filteredTransactions = transactions.filter((tx) => {
if (activeTab === "verified")
return tx.status === "CONFIRMED" || tx.status === "SUCCESS";
if (activeTab === "pending")
return tx.status === "PENDING" || tx.status === "PROCESSING";
return true;
});
return ( return (
<div className="p-6 space-y-6 max-w-[1400px] mx-auto"> <div className="relative min-h-screen bg-background overflow-hidden">
{/* Ambient Background */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-[-10%] left-[-10%] w-[600px] h-[600px] rounded-full bg-primary/5 blur-[120px]" />
<div className="absolute bottom-[-10%] right-[-10%] w-[700px] h-[700px] rounded-full bg-emerald-500/5 blur-[120px]" />
<div className="absolute top-[40%] left-[60%] w-[400px] h-[400px] rounded-full bg-violet-500/5 blur-[100px]" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:64px_64px] [mask-image:radial-gradient(ellipse_80%_80%_at_50%_50%,#000_20%,transparent_100%)]" />
</div>
<div className="relative z-10 p-6 lg:p-8 space-y-8 max-w-[1440px] mx-auto">
{/* Page Header */} {/* Page Header */}
<motion.div <motion.div
initial={{ opacity: 0, y: -20 }} initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between" className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"
> >
<div> <div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-3"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20"> <motion.div
<Blocks className="w-6 h-6 text-primary" /> whileHover={{ rotate: 180 }}
</div> transition={{ duration: 0.6 }}
className="relative p-2.5 rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/20 shadow-lg shadow-primary/10"
>
<Blocks className="w-7 h-7 text-primary" />
<div className="absolute inset-0 rounded-2xl bg-primary/20 blur-md -z-10" />
</motion.div>
<div>
<h1 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-muted-foreground bg-clip-text text-transparent">
Blockchain Explorer Blockchain Explorer
</h1> </h1>
<p className="text-muted-foreground mt-1 text-sm"> </div>
View on-chain proofs and verify document integrity </div>
<p className="text-muted-foreground text-sm max-w-lg leading-relaxed">
Immutable proof-of-existence registry. Verify document integrity,
audit on-chain history, and register new contracts.
</p> </p>
</div> </div>
<div className="flex items-center gap-3">
<div className="hidden sm:flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border/30">
<Radio className="w-3 h-3 text-emerald-500" />
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500" />
</span>
Live
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={loadData} onClick={loadData}
disabled={loading} disabled={loading}
className="gap-2" className="gap-2 rounded-xl border-border/60 bg-background/50 backdrop-blur-xl hover:bg-background/80 transition-all"
> >
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} /> <RefreshCw
className={`w-4 h-4 ${loading ? "animate-spin" : ""}`}
/>
Refresh Refresh
</Button> </Button>
</div>
</motion.div> </motion.div>
{/* Stats Cards */} {/* Stats Bento Grid */}
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
> >
<StatsCard <BentoCard
icon={<Shield className="w-5 h-5" />} icon={<Shield className="w-5 h-5" />}
label="Verified Documents" label="Verified Documents"
value={stats?.totalVerified?.toString() ?? "0"} value={stats?.totalVerified?.toString() ?? "0"}
color="emerald" subtitle="On-chain proofs"
gradient="from-emerald-500/20 to-emerald-500/5"
border="border-emerald-500/20"
iconColor="text-emerald-500"
delay={0}
/> />
<StatsCard <BentoCard
icon={<Blocks className="w-5 h-5" />} icon={<Blocks className="w-5 h-5" />}
label="Latest Block" label="Latest Block"
value={stats?.latestBlockNumber ? `#${stats.latestBlockNumber.toLocaleString()}` : "—"} value={
color="blue" stats?.latestBlockNumber
? `#${stats.latestBlockNumber.toLocaleString()}`
: "—"
}
subtitle="Network height"
gradient="from-blue-500/20 to-blue-500/5"
border="border-blue-500/20"
iconColor="text-blue-500"
delay={0.05}
/> />
<StatsCard <BentoCard
icon={<Activity className="w-5 h-5" />} icon={<Globe className="w-5 h-5" />}
label="Network" label="Network Status"
value={stats?.networkName ? `${stats.networkName} ${stats.chainId ? `(Chain ${stats.chainId})` : ""}` : "Not Configured"} value={stats?.networkName || "Not Configured"}
color={stats?.networkStatus === "connected" ? "emerald" : "red"} subtitle={
badge={stats?.networkStatus === "connected" ? "● Live" : "● Offline"} stats?.chainId ? `Chain ID ${stats.chainId}` : "Disconnected"
}
gradient={
stats?.networkStatus === "connected"
? "from-emerald-500/20 to-emerald-500/5"
: "from-red-500/20 to-red-500/5"
}
border={
stats?.networkStatus === "connected"
? "border-emerald-500/20"
: "border-red-500/20"
}
iconColor={
stats?.networkStatus === "connected"
? "text-emerald-500"
: "text-red-500"
}
badge={
stats?.networkStatus === "connected" ? (
<span className="flex items-center gap-1.5 text-[10px] font-bold text-emerald-600 dark:text-emerald-400">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</span>
LIVE
</span>
) : (
<span className="flex items-center gap-1.5 text-[10px] font-bold text-red-600 dark:text-red-400">
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
OFFLINE
</span>
)
}
delay={0.1}
/> />
<StatsCard <BentoCard
icon={<Hash className="w-5 h-5" />} icon={<Fingerprint className="w-5 h-5" />}
label="Wallet" label="Wallet"
value={stats?.walletAddress ? truncateHash(stats.walletAddress, 6) : "—"} value={
color="violet" stats?.walletAddress ? truncateHash(stats.walletAddress, 6) : "—"
}
subtitle="Connected address"
gradient="from-violet-500/20 to-violet-500/5"
border="border-violet-500/20"
iconColor="text-violet-500"
delay={0.15}
/> />
</motion.div> </motion.div>
{/* Unregistered contracts */} {/* Unregistered Contracts Alert */}
<AnimatePresence>
{unregisteredContracts.length > 0 && ( {unregisteredContracts.length > 0 && (
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, height: "auto" }}
transition={{ delay: 0.15 }} exit={{ opacity: 0, height: 0 }}
className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-5" className="overflow-hidden"
> >
<div className="flex items-center gap-2 mb-3"> <div className="relative rounded-2xl border border-amber-500/20 bg-gradient-to-r from-amber-500/10 via-amber-500/5 to-transparent p-1">
<div className="absolute inset-0 rounded-2xl bg-amber-500/5 blur-xl" />
<div className="relative p-5">
<div className="flex items-center gap-2 mb-4">
<div className="p-1.5 rounded-lg bg-amber-500/20">
<Upload className="w-4 h-4 text-amber-500" /> <Upload className="w-4 h-4 text-amber-500" />
<h3 className="text-sm font-semibold text-amber-600 dark:text-amber-400"> </div>
{unregisteredContracts.length} contract{unregisteredContracts.length > 1 ? "s" : ""} not yet on blockchain <h3 className="text-sm font-bold text-amber-600 dark:text-amber-400 uppercase tracking-wider">
{unregisteredContracts.length} pending registration
</h3> </h3>
</div> </div>
<div className="space-y-2"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{unregisteredContracts.map((contract) => ( {unregisteredContracts.map((contract) => (
<div <motion.div
key={contract.id} key={contract.id}
className="flex items-center justify-between rounded-xl bg-background/60 border border-border/40 px-4 py-2.5" initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="group flex items-center justify-between rounded-xl bg-background/60 backdrop-blur-md border border-border/40 px-4 py-3 hover:border-amber-500/30 hover:bg-background/80 transition-all"
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<FileText className="w-4 h-4 text-muted-foreground shrink-0" /> <div className="p-1.5 rounded-md bg-muted">
<span className="text-sm truncate"> <FileText className="w-3.5 h-3.5 text-muted-foreground" />
</div>
<span className="text-sm truncate font-medium">
{contract.title || contract.fileName} {contract.title || contract.fileName}
</span> </span>
</div> </div>
<Button <Button
size="sm" size="sm"
variant="outline" className="gap-1.5 text-xs rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 hover:bg-amber-500/20 hover:text-amber-700 dark:hover:text-amber-300 shrink-0 ml-3"
className="gap-1.5 text-xs shrink-0 ml-3"
disabled={registeringId === contract.id} disabled={registeringId === contract.id}
onClick={() => handleRegister(contract.id)} onClick={() => handleRegister(contract.id)}
> >
{registeringId === contract.id ? ( {registeringId === contract.id ? (
<RefreshCw className="w-3 h-3 animate-spin" /> <RefreshCw className="w-3 h-3 animate-spin" />
) : ( ) : (
<Link2 className="w-3 h-3" /> <Zap className="w-3 h-3" />
)} )}
Register Register
</Button> </Button>
</div> </motion.div>
))} ))}
</div> </div>
</div>
</div>
</motion.div> </motion.div>
)} )}
</AnimatePresence>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Transactions List */} {/* Transactions List */}
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
className="lg:col-span-2 rounded-2xl border border-border/60 bg-background/80 backdrop-blur-xl overflow-hidden" className="lg:col-span-2 rounded-2xl border border-border/40 bg-background/40 backdrop-blur-2xl overflow-hidden shadow-2xl shadow-black/5"
> >
<div className="p-5 border-b border-border/40"> <div className="p-5 border-b border-border/40 flex items-center justify-between">
<h2 className="font-semibold text-foreground flex items-center gap-2"> <div>
<Link2 className="w-4 h-4 text-primary" /> <h2 className="font-bold text-foreground flex items-center gap-2 text-lg">
<Link2 className="w-5 h-5 text-primary" />
Transaction History Transaction History
</h2> </h2>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
All documents registered on the blockchain Immutable audit trail of all registered documents
</p> </p>
</div> </div>
<div className="flex items-center gap-1 bg-muted/50 rounded-lg p-1 border border-border/30">
{(["all", "verified", "pending"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
activeTab === tab
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
</div>
{loading ? ( {loading ? (
<div className="p-10 text-center text-muted-foreground"> <div className="p-12">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" /> <div className="space-y-4">
Loading transactions... {[1, 2, 3].map((i) => (
<div
key={i}
className="flex items-center gap-4 p-4 rounded-xl bg-muted/30 animate-pulse"
>
<div className="w-10 h-10 rounded-full bg-muted" />
<div className="space-y-2 flex-1">
<div className="h-3 bg-muted rounded w-1/3" />
<div className="h-2 bg-muted rounded w-1/2" />
</div> </div>
) : transactions.length === 0 ? ( </div>
<div className="p-10 text-center text-muted-foreground"> ))}
<Blocks className="w-8 h-8 mx-auto mb-3 opacity-50" /> </div>
<p className="text-sm font-medium">No transactions yet</p> </div>
<p className="text-xs mt-1"> ) : filteredTransactions.length === 0 ? (
Upload and analyze a contract to register it on-chain <div className="p-16 text-center">
<div className="relative inline-flex mb-4">
<div className="absolute inset-0 bg-primary/20 blur-xl rounded-full" />
<Blocks className="w-12 h-12 text-muted-foreground relative z-10" />
</div>
<p className="text-sm font-medium text-foreground">
No transactions found
</p>
<p className="text-xs mt-2 text-muted-foreground max-w-xs mx-auto">
{activeTab !== "all"
? `No ${activeTab} transactions match your filter.`
: "Upload and analyze a contract to register it on-chain."}
</p> </p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-border/40"> <div className="divide-y divide-border/30">
{transactions.map((tx, idx) => ( <AnimatePresence mode="popLayout">
{filteredTransactions.map((tx, idx) => (
<motion.div <motion.div
key={tx.id} key={tx.id}
initial={{ opacity: 0 }} layout
animate={{ opacity: 1 }} initial={{ opacity: 0, x: -10 }}
transition={{ delay: idx * 0.05 }} animate={{ opacity: 1, x: 0 }}
className="p-4 hover:bg-muted/30 transition-colors" exit={{ opacity: 0, scale: 0.98 }}
transition={{ delay: idx * 0.03 }}
className="group p-5 hover:bg-gradient-to-r hover:from-primary/5 hover:to-transparent transition-all duration-300"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 space-y-3">
<div className="flex items-center gap-2 mb-1"> {/* Title Row */}
<CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0" /> <div className="flex items-center gap-3">
<span className="text-sm font-medium truncate"> <div className="p-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
</div>
<span className="text-sm font-semibold truncate">
{tx.contractTitle || tx.contractFileName} {tx.contractTitle || tx.contractFileName}
</span> </span>
<span className="shrink-0 text-[10px] font-medium px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"> <span className="shrink-0 text-[10px] font-bold px-2.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 uppercase tracking-wider">
{tx.status} {tx.status}
</span> </span>
</div> </div>
<div className="space-y-1 mt-2"> {/* Metadata Grid */}
<div className="flex items-center gap-2 text-xs text-muted-foreground group"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 pl-[2.25rem]">
<Shield className="w-3 h-3 shrink-0 text-emerald-500/70" /> <div className="flex items-center gap-2 text-xs text-muted-foreground group/hash">
<span className="font-mono text-[10px]">Fingerprint: {truncateHash(tx.documentHash, 12)}</span> <Fingerprint className="w-3.5 h-3.5 shrink-0 text-primary/60" />
<span className="font-mono text-[11px] tracking-tight">
{truncateHash(tx.documentHash, 14)}
</span>
<button <button
onClick={() => copyToClipboard(tx.documentHash)} onClick={() => copyToClipboard(tx.documentHash)}
className="opacity-0 group-hover:opacity-100 transition-opacity hover:text-foreground" className="opacity-0 group-hover/hash:opacity-100 transition-all hover:scale-110"
title="Copy Document Fingerprint"
> >
{copiedTx === tx.documentHash ? ( {copiedTx === tx.documentHash ? (
<Check className="w-3 h-3 text-emerald-500" /> <Check className="w-3 h-3 text-emerald-500" />
@@ -328,13 +525,15 @@ export default function BlockchainExplorerPage() {
)} )}
</button> </button>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Hash className="w-3 h-3 shrink-0" /> <div className="flex items-center gap-2 text-xs text-muted-foreground group/hash">
<span className="font-mono">Tx: {truncateHash(tx.txHash, 12)}</span> <Hash className="w-3.5 h-3.5 shrink-0 text-primary/60" />
<span className="font-mono text-[11px] tracking-tight">
{truncateHash(tx.txHash, 14)}
</span>
<button <button
onClick={() => copyToClipboard(tx.txHash)} onClick={() => copyToClipboard(tx.txHash)}
className="hover:text-foreground transition-colors" className="opacity-0 group-hover/hash:opacity-100 transition-all hover:scale-110"
title="Copy Transaction Hash"
> >
{copiedTx === tx.txHash ? ( {copiedTx === tx.txHash ? (
<Check className="w-3 h-3 text-emerald-500" /> <Check className="w-3 h-3 text-emerald-500" />
@@ -343,190 +542,287 @@ export default function BlockchainExplorerPage() {
)} )}
</button> </button>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Blocks className="w-3 h-3 shrink-0" /> <Blocks className="w-3.5 h-3.5 shrink-0 text-primary/60" />
<span className="font-mono">
Block #{tx.blockNumber.toLocaleString()} Block #{tx.blockNumber.toLocaleString()}
</span>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3 shrink-0" /> <Clock className="w-3.5 h-3.5 shrink-0 text-primary/60" />
{formatTimestamp(tx.blockTimestamp)} <span>{formatTimestamp(tx.blockTimestamp)}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Right Side Actions */}
<div className="flex flex-col items-end gap-2 shrink-0"> <div className="flex flex-col items-end gap-2 shrink-0">
<span className="text-[10px] font-medium px-2 py-0.5 rounded bg-muted text-muted-foreground"> <span className="text-[10px] font-bold px-2.5 py-1 rounded-md bg-muted border border-border/40 uppercase tracking-wider">
{tx.network === "sepolia" ? "Sepolia" : "Hardhat"} {tx.network === "sepolia" ? "Sepolia" : "Hardhat"}
</span> </span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="text-xs h-8 gap-1.5"
disabled={
downloadingCertificateId === tx.contractId
}
onClick={() =>
handleDownloadCertificate(
tx.contractId,
tx.contractTitle || tx.contractFileName,
)
}
title="Download cryptographic certificate"
>
{downloadingCertificateId === tx.contractId ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Fingerprint className="w-3 h-3" />
)}
Certificate
</Button>
{tx.explorerUrl && ( {tx.explorerUrl && (
<a <a
href={tx.explorerUrl} href={tx.explorerUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1" className="text-xs text-primary hover:text-primary/80 flex items-center gap-1 font-medium group/link"
> >
Etherscan Etherscan
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3 group-hover/link:translate-x-0.5 group-hover/link:-translate-y-0.5 transition-transform" />
</a> </a>
)} )}
</div> </div>
</div> </div>
</div>
</motion.div> </motion.div>
))} ))}
</AnimatePresence>
</div> </div>
)} )}
</motion.div> </motion.div>
{/* Verification Panel */} {/* Verification Panel */}
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }} transition={{ delay: 0.3 }}
className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-xl h-fit" className="space-y-6"
> >
<div className="p-5 border-b border-border/40"> <div className="rounded-2xl border border-border/40 bg-background/40 backdrop-blur-2xl shadow-2xl shadow-black/5 overflow-hidden">
<h2 className="font-semibold text-foreground flex items-center gap-2"> <div className="p-5 border-b border-border/40 bg-gradient-to-r from-primary/5 to-transparent">
<Search className="w-4 h-4 text-primary" /> <h2 className="font-bold text-foreground flex items-center gap-2 text-lg">
<Search className="w-5 h-5 text-primary" />
Verify Document Verify Document
</h2> </h2>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Check if a document hash exists on-chain Cryptographic on-chain verification
</p> </p>
</div> </div>
<div className="p-5 space-y-4"> <div className="p-5 space-y-5">
<div> <div className="relative">
<label className="text-xs font-medium text-muted-foreground mb-1.5 block"> <label className="text-xs font-bold text-muted-foreground mb-2 block uppercase tracking-wider">
Document Hash (SHA-256) Document Hash
</label> </label>
<div className="relative">
<textarea <textarea
value={verifyHash} value={verifyHash}
onChange={(e) => setVerifyHash(e.target.value)} onChange={(e) => setVerifyHash(e.target.value)}
placeholder="0x..." placeholder="Paste SHA-256 hash (0x...)"
className="w-full rounded-xl border border-border/60 bg-muted/20 px-3 py-2.5 text-xs font-mono resize-none h-20 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/40 transition-all" className="w-full rounded-xl border border-border/60 bg-muted/20 px-4 py-3.5 text-xs font-mono resize-none h-24 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 focus:bg-background/60 transition-all placeholder:text-muted-foreground/50"
/> />
<div className="absolute bottom-2 right-2 text-[10px] text-muted-foreground font-mono bg-background/80 px-2 py-0.5 rounded border border-border/30">
SHA-256
</div>
</div>
</div> </div>
<Button <Button
onClick={handleVerify} onClick={handleVerify}
disabled={!verifyHash.trim() || verifying} disabled={!verifyHash.trim() || verifying}
className="w-full gap-2" className="w-full gap-2 rounded-xl h-10 font-semibold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all"
size="sm" size="sm"
> >
{verifying ? ( {verifying ? (
<RefreshCw className="w-4 h-4 animate-spin" /> <RefreshCw className="w-4 h-4 animate-spin" />
) : ( ) : (
<Search className="w-4 h-4" /> <Shield className="w-4 h-4" />
)} )}
Verify On-Chain Verify On-Chain
</Button> </Button>
{/* Verification Result */} {/* Verification Result */}
<AnimatePresence mode="wait">
{verifyResult !== null && ( {verifyResult !== null && (
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} key={verifyResult.exists ? "found" : "notfound"}
animate={{ opacity: 1, scale: 1 }} initial={{ opacity: 0, y: 10, scale: 0.98 }}
className={`rounded-xl border p-4 ${ animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
className={`relative overflow-hidden rounded-xl border p-5 ${
verifyResult.exists verifyResult.exists
? "border-emerald-500/30 bg-emerald-500/5" ? "border-emerald-500/30 bg-gradient-to-br from-emerald-500/10 via-emerald-500/5 to-transparent"
: "border-red-500/30 bg-red-500/5" : "border-red-500/30 bg-gradient-to-br from-red-500/10 via-red-500/5 to-transparent"
}`} }`}
> >
<div className="flex items-center gap-2 mb-3"> {verifyResult.exists && (
{verifyResult.exists ? ( <div className="absolute top-0 right-0 p-3 opacity-10">
<CheckCircle2 className="w-5 h-5 text-emerald-500" /> <Shield className="w-24 h-24 text-emerald-500" />
) : ( </div>
<AlertCircle className="w-5 h-5 text-red-500" />
)} )}
<div className="relative z-10">
<div className="flex items-center gap-3 mb-4">
<div
className={`p-2 rounded-full ${
verifyResult.exists
? "bg-emerald-500/20 text-emerald-500"
: "bg-red-500/20 text-red-500"
}`}
>
{verifyResult.exists ? (
<CheckCircle2 className="w-6 h-6" />
) : (
<AlertCircle className="w-6 h-6" />
)}
</div>
<div>
<span <span
className={`text-sm font-semibold ${ className={`text-sm font-bold block ${
verifyResult.exists verifyResult.exists
? "text-emerald-600 dark:text-emerald-400" ? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400" : "text-red-600 dark:text-red-400"
}`} }`}
> >
{verifyResult.exists {verifyResult.exists
? "Document Verified" ? "Document Verified"
: "Not Found"} : "Not Found On-Chain"}
</span> </span>
<span className="text-[10px] text-muted-foreground uppercase tracking-wider font-bold">
{verifyResult.exists
? "Cryptographic proof valid"
: "No matching hash in registry"}
</span>
</div>
</div> </div>
{verifyResult.exists && ( {verifyResult.exists && (
<div className="space-y-2 text-xs"> <div className="space-y-3 text-xs">
<div className="flex justify-between"> <div className="flex items-center justify-between p-2.5 rounded-lg bg-background/40 border border-border/30">
<span className="text-muted-foreground">Timestamp</span> <span className="text-muted-foreground flex items-center gap-2">
<span className="font-mono"> <Clock className="w-3 h-3" />
{new Date(verifyResult.timestamp * 1000).toLocaleString()} Timestamp
</span>
<span className="font-mono font-medium">
{new Date(
verifyResult.timestamp * 1000,
).toLocaleString()}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex items-center justify-between p-2.5 rounded-lg bg-background/40 border border-border/30">
<span className="text-muted-foreground">Depositor</span> <span className="text-muted-foreground flex items-center gap-2">
<span className="font-mono"> <Fingerprint className="w-3 h-3" />
{truncateHash(verifyResult.depositor, 6)} Depositor
</span>
<span className="font-mono font-medium">
{truncateHash(verifyResult.depositor, 8)}
</span> </span>
</div> </div>
</div> </div>
)} )}
</motion.div>
)}
</div> </div>
</motion.div> </motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Quick Info Card */}
<div className="rounded-2xl border border-border/40 bg-gradient-to-br from-primary/5 via-transparent to-violet-500/5 p-5 backdrop-blur-xl">
<h3 className="text-sm font-bold mb-2 flex items-center gap-2">
<Terminal className="w-4 h-4 text-primary" />
How it works
</h3>
<ul className="space-y-2 text-xs text-muted-foreground">
<li className="flex items-start gap-2">
<ChevronRight className="w-3 h-3 mt-0.5 text-primary shrink-0" />
Documents are hashed using SHA-256 before registration
</li>
<li className="flex items-start gap-2">
<ChevronRight className="w-3 h-3 mt-0.5 text-primary shrink-0" />
Proofs are permanently stored on the Ethereum blockchain
</li>
<li className="flex items-start gap-2">
<Lock className="w-3 h-3 mt-0.5 text-primary shrink-0" />
Anyone can verify integrity without revealing document content
</li>
</ul>
</div>
</motion.div>
</div>
</div> </div>
</div> </div>
); );
} }
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
// Stats Card Sub-Component // Bento Card Sub-Component
// ───────────────────────────────────────────────── // ─────────────────────────────────────────────────
function StatsCard({ function BentoCard({
icon, icon,
label, label,
value, value,
color, subtitle,
gradient,
border,
iconColor,
badge, badge,
delay,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
value: string; value: string;
color: string; subtitle: string;
badge?: string; gradient: string;
border: string;
iconColor: string;
badge?: React.ReactNode;
delay: number;
}) { }) {
const colorMap: Record<string, string> = {
emerald: "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
blue: "text-blue-600 dark:text-blue-400 bg-blue-500/10 border-blue-500/20",
violet: "text-violet-600 dark:text-violet-400 bg-violet-500/10 border-violet-500/20",
red: "text-red-600 dark:text-red-400 bg-red-500/10 border-red-500/20",
};
const iconColors: Record<string, string> = {
emerald: "text-emerald-500",
blue: "text-blue-500",
violet: "text-violet-500",
red: "text-red-500",
};
return ( return (
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-xl p-4"> <motion.div
<div className="flex items-center justify-between mb-3"> initial={{ opacity: 0, y: 20 }}
<div className={`p-2 rounded-lg border ${colorMap[color]}`}> animate={{ opacity: 1, y: 0 }}
<span className={iconColors[color]}>{icon}</span> transition={{ delay: 0.1 + delay }}
</div> className={`group relative rounded-2xl border ${border} bg-gradient-to-br ${gradient} p-5 backdrop-blur-xl overflow-hidden hover:scale-[1.02] transition-transform duration-300`}
{badge && (
<span
className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${
badge.includes("Live")
? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
: "text-red-600 dark:text-red-400 bg-red-500/10"
}`}
> >
{badge} <div className="absolute inset-0 bg-background/40" />
</span> <div className="absolute -right-4 -top-4 w-24 h-24 bg-gradient-to-br from-white/10 to-transparent rounded-full blur-2xl group-hover:opacity-100 opacity-0 transition-opacity" />
)}
<div className="relative z-10">
<div className="flex items-center justify-between mb-4">
<div
className={`p-2.5 rounded-xl bg-background/60 border border-border/40 shadow-sm ${iconColor}`}
>
{icon}
</div> </div>
<p className="text-xs text-muted-foreground">{label}</p> {badge && <div>{badge}</div>}
<p className="text-lg font-bold text-foreground mt-0.5 truncate">{value}</p>
</div> </div>
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-[0.2em]">
{label}
</p>
<p className="text-2xl font-bold text-foreground mt-1 truncate tracking-tight">
{value}
</p>
<p className="text-[11px] text-muted-foreground mt-1">{subtitle}</p>
</div>
</motion.div>
); );
} }

View File

@@ -0,0 +1,3 @@
{
"address": "0x5FbDB2315678afecb367f032d93F642f64180aa3"
}

View File

@@ -2,6 +2,9 @@
"name": "lexichain-blockchain", "name": "lexichain-blockchain",
"version": "1.0.0", "version": "1.0.0",
"description": "LexiChain Document Registry - Solidity Smart Contract", "description": "LexiChain Document Registry - Solidity Smart Contract",
"engines": {
"node": ">=20 <23"
},
"scripts": { "scripts": {
"compile": "hardhat compile", "compile": "hardhat compile",
"test": "hardhat test", "test": "hardhat test",

View File

@@ -0,0 +1,103 @@
# Deployment Guide (OpenStack + Docker)
This guide deploys the Next.js app as a Docker container on a private OpenStack environment.
## 1) Prerequisites
- OpenStack project with a VM (Ubuntu 22.04 or similar)
- Docker Engine installed on the VM
- A PostgreSQL database reachable from the VM
- A blockchain RPC endpoint (Sepolia or private chain)
- A deployed DocumentRegistry contract address
## 2) Build the Docker image (local or CI)
From the project root:
```
docker build -t lexichain-app:latest .
```
Optionally tag and push to your private registry.
## 3) Runtime environment variables
Create a file named `lexichain.env` on the VM with the required secrets.
Example (fill with your real values):
```
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/DBNAME
CLERK_PUBLISHABLE_KEY=...
CLERK_SECRET_KEY=...
CLERK_WEBHOOK_SECRET=...
UPLOADTHING_SECRET=...
UPLOADTHING_APP_ID=...
GOOGLE_AI_API_KEY=...
BLOCKCHAIN_NETWORK=sepolia
BLOCKCHAIN_RPC_URL=https://...
BLOCKCHAIN_CONTRACT_ADDRESS=0x...
BLOCKCHAIN_PRIVATE_KEY=0x...
```
If you use a private chain, set `BLOCKCHAIN_NETWORK` accordingly.
## 4) Run the container on the VM
```
docker run -d \
--name lexichain-app \
--restart unless-stopped \
--env-file /path/to/lexichain.env \
-p 3000:3000 \
lexichain-app:latest
```
## 5) Database migration (first deploy)
Run migrations from the container image (or from CI) before first launch:
```
docker run --rm \
--env-file /path/to/lexichain.env \
lexichain-app:latest \
npx prisma migrate deploy
```
## 6) OpenStack security group / firewall
Allow inbound traffic to port 3000 from your internal network or from the reverse proxy.
## 7) Optional: reverse proxy
Place Nginx or HAProxy in front of the app for TLS termination and HTTP/2.
## 8) Health check
Open `http://<vm-ip>:3000` and validate:
- Sign-in flow
- Upload + AI analysis
- Blockchain explorer stats
- Document verification
## 9) Update / rollout
- Build a new image and push to your registry.
- Pull on the VM and restart the container:
```
docker pull registry.example.com/lexichain-app:latest
docker stop lexichain-app
docker rm lexichain-app
docker run -d \
--name lexichain-app \
--restart unless-stopped \
--env-file /path/to/lexichain.env \
-p 3000:3000 \
registry.example.com/lexichain-app:latest
```

View File

@@ -19,7 +19,12 @@ import { prisma } from "@/lib/db/prisma";
import { BlockchainService } from "@/lib/services/blockchain.service"; import { BlockchainService } from "@/lib/services/blockchain.service";
import { NotificationService } from "@/lib/services/notification.service"; import { NotificationService } from "@/lib/services/notification.service";
import { ContractService } from "@/lib/services/contract.service"; import { ContractService } from "@/lib/services/contract.service";
import type { BlockchainTransactionView, BlockchainStats } from "@/lib/services/blockchain.types"; import { CertificateService } from "@/lib/services/certificate.service";
import type {
BlockchainTransactionView,
BlockchainStats,
CryptographicCertificate,
} from "@/lib/services/blockchain.types";
/** /**
* Register a contract's document on the blockchain. * Register a contract's document on the blockchain.
@@ -44,7 +49,8 @@ export async function registerContractOnBlockchain(contractId: string) {
if (!BlockchainService.isConfigured()) { if (!BlockchainService.isConfigured()) {
return { return {
success: false, success: false,
error: "Blockchain not configured. Start a Hardhat node and check your .env.", error:
"Blockchain not configured. Start a Hardhat node and check your .env.",
}; };
} }
@@ -74,7 +80,7 @@ export async function registerContractOnBlockchain(contractId: string) {
// Hash the document and register on-chain // Hash the document and register on-chain
const proof = await BlockchainService.hashAndRegister( const proof = await BlockchainService.hashAndRegister(
contract.fileUrl, contract.fileUrl,
contract.fileName contract.fileName,
); );
// Save proof data to the Contract record // Save proof data to the Contract record
@@ -137,7 +143,8 @@ export async function registerContractOnBlockchain(contractId: string) {
console.error("❌ Blockchain registration error:", error); console.error("❌ Blockchain registration error:", error);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : "Unknown blockchain error", error:
error instanceof Error ? error.message : "Unknown blockchain error",
}; };
} }
} }
@@ -151,14 +158,18 @@ export async function verifyContractOnBlockchain(contractId: string) {
if (!clerkId) return { success: false, error: "Unauthorized" }; if (!clerkId) return { success: false, error: "Unauthorized" };
if (!BlockchainService.isReadConfigured()) { if (!BlockchainService.isReadConfigured()) {
return { success: false, error: "Blockchain not configured for verification" }; return {
success: false,
error: "Blockchain not configured for verification",
};
} }
const user = await ContractService.getUserByClerkId(clerkId); const user = await ContractService.getUserByClerkId(clerkId);
if (!user) return { success: false, error: "User not found" }; if (!user) return { success: false, error: "User not found" };
const contract = await ContractService.getById(contractId); const contract = await ContractService.getById(contractId);
if (contract.userId !== user.id) return { success: false, error: "Unauthorized" }; if (contract.userId !== user.id)
return { success: false, error: "Unauthorized" };
if (!contract.documentHash) { if (!contract.documentHash) {
return { return {
@@ -167,7 +178,9 @@ export async function verifyContractOnBlockchain(contractId: string) {
}; };
} }
const verification = await BlockchainService.verifyOnChain(contract.documentHash); const verification = await BlockchainService.verifyOnChain(
contract.documentHash,
);
return { return {
success: true, success: true,
@@ -199,7 +212,10 @@ export async function verifyDocumentHashOnBlockchain(documentHash: string) {
if (!clerkId) return { success: false, error: "Unauthorized" }; if (!clerkId) return { success: false, error: "Unauthorized" };
if (!BlockchainService.isReadConfigured()) { if (!BlockchainService.isReadConfigured()) {
return { success: false, error: "Blockchain not configured for verification" }; return {
success: false,
error: "Blockchain not configured for verification",
};
} }
// Ensure proper format // Ensure proper format
@@ -313,3 +329,97 @@ export async function getBlockchainStats(): Promise<{
}; };
} }
} }
/**
* Generate a cryptographic certificate for a blockchain-registered contract.
*
* The certificate contains:
* - Contract metadata
* - Blockchain proof (txHash, block, timestamp)
* - A digital signature created by the server wallet
*
* @param contractId - The contract ID
* @returns Certificate data (JSON) or error
*/
export async function generateContractCertificate(contractId: string): Promise<{
success: boolean;
certificate?: CryptographicCertificate;
certificateJson?: string;
certificatePdfBase64?: string;
certificateFileName?: string;
error?: string;
}> {
try {
const { userId: clerkId } = await auth();
if (!clerkId) return { success: false, error: "Unauthorized" };
if (!BlockchainService.isReadConfigured()) {
return {
success: false,
error: "Blockchain not configured for certificate generation",
};
}
const user = await ContractService.getUserByClerkId(clerkId);
if (!user) return { success: false, error: "User not found" };
const contract = await ContractService.getById(contractId);
if (contract.userId !== user.id) {
return {
success: false,
error: "Unauthorized: Contract does not belong to you",
};
}
// Check if contract is registered on blockchain
if (!contract.documentHash || !contract.txHash) {
return {
success: false,
error:
"Contract is not registered on blockchain. Register it first to generate a certificate.",
};
}
// Generate the certificate
const certificate = await CertificateService.generateCertificate(
contract.id,
contract.title,
contract.fileName,
contract.documentHash,
contract.txHash,
contract.blockNumber || 0,
contract.blockTimestamp?.toISOString() || new Date().toISOString(),
contract.blockchainNetwork || "hardhat",
contract.contractAddress || "",
);
// Generate a professional PDF for download
const pdfBuffer =
await CertificateService.generateCertificatePdf(certificate);
const certificatePdfBase64 = pdfBuffer.toString("base64");
const baseName = (contract.title || contract.fileName || contract.id)
.replace(/[^a-zA-Z0-9-_]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
const dateStamp = new Date().toISOString().slice(0, 10);
const certificateFileName = `certificate-${baseName || contract.id}-${dateStamp}.pdf`;
// Keep JSON available for internal use or auditing if needed
const certificateJson = JSON.stringify(certificate, null, 2);
return {
success: true,
certificate,
certificateJson,
certificatePdfBase64,
certificateFileName,
};
} catch (error: unknown) {
console.error("❌ Certificate generation error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useEffect, useState } from "react";
import { Clock, AlertCircle } from "lucide-react";
interface ContractCountdownProps {
endDate?: string | null;
className?: string;
}
export function ContractCountdown({
endDate,
className = "",
}: ContractCountdownProps) {
const [daysLeft, setDaysLeft] = useState<number | null>(null);
const [isExpired, setIsExpired] = useState(false);
useEffect(() => {
if (!endDate) {
setDaysLeft(null);
return;
}
const calculateDaysLeft = () => {
const end = new Date(endDate);
const today = new Date();
// Reset time for comparison
end.setHours(0, 0, 0, 0);
today.setHours(0, 0, 0, 0);
const timeDiff = end.getTime() - today.getTime();
const days = Math.ceil(timeDiff / (1000 * 3600 * 24));
setIsExpired(days < 0);
setDaysLeft(days);
};
calculateDaysLeft();
// Update every minute to keep countdown fresh
const interval = setInterval(calculateDaysLeft, 60000);
return () => clearInterval(interval);
}, [endDate]);
if (daysLeft === null) {
return null;
}
// Determine colors based on urgency
const getUrgencyStyle = () => {
if (isExpired) {
return {
bg: "bg-red-500/15",
border: "border-red-500/40",
text: "text-red-700 dark:text-red-300",
icon: "text-red-500",
label: "Expired",
};
}
if (daysLeft < 7) {
return {
bg: "bg-red-500/15",
border: "border-red-500/40",
text: "text-red-700 dark:text-red-300",
icon: "text-red-500",
label: `${daysLeft} day${daysLeft !== 1 ? "s" : ""} left`,
};
}
if (daysLeft < 14) {
return {
bg: "bg-orange-500/15",
border: "border-orange-500/40",
text: "text-orange-700 dark:text-orange-300",
icon: "text-orange-500",
label: `${daysLeft} days left`,
};
}
if (daysLeft < 30) {
return {
bg: "bg-amber-500/15",
border: "border-amber-500/40",
text: "text-amber-700 dark:text-amber-300",
icon: "text-amber-500",
label: `${daysLeft} days left`,
};
}
return {
bg: "bg-green-500/15",
border: "border-green-500/40",
text: "text-green-700 dark:text-green-300",
icon: "text-green-500",
label: `${daysLeft} days left`,
};
};
const urgency = getUrgencyStyle();
return (
<div
className={`inline-flex items-center gap-2 rounded-full border ${urgency.bg} ${urgency.border} px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider ${urgency.text} ${className}`}
>
{isExpired ? (
<AlertCircle className={`h-3.5 w-3.5 ${urgency.icon}`} />
) : (
<Clock className={`h-3.5 w-3.5 ${urgency.icon} animate-pulse`} />
)}
{urgency.label}
</div>
);
}

View File

@@ -60,6 +60,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { ContractChatModal } from "@/features/contracts/components/modals/contract-chat-modal"; import { ContractChatModal } from "@/features/contracts/components/modals/contract-chat-modal";
import { ContractProofModal } from "@/features/contracts/components/modals/contract-proof-modal"; import { ContractProofModal } from "@/features/contracts/components/modals/contract-proof-modal";
import { ContractCountdown } from "@/features/contracts/components/contract-countdown";
import { import {
stripMarkdown, stripMarkdown,
exportToCSV, exportToCSV,
@@ -1122,6 +1123,10 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
/> />
{status.label} {status.label}
</span> </span>
{contract.endDate &&
contract.status === "COMPLETED" && (
<ContractCountdown endDate={contract.endDate} />
)}
{contract.isRagged && ( {contract.isRagged && (
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-cyan-700 dark:text-cyan-300"> <span className="inline-flex items-center gap-1 rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-cyan-700 dark:text-cyan-300">
<Network className="h-3 w-3" /> <Network className="h-3 w-3" />

View File

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

View File

@@ -48,6 +48,37 @@ export interface BlockchainTransactionView {
explorerUrl: string | null; 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. * 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; 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 transporter: nodemailer.Transporter | null = null;
let transportMode: "smtp" | "ethereal" | null = null; let transportMode: "smtp" | "ethereal" | null = null;
let hasWarnedMissingEmailConfig = false; let hasWarnedMissingEmailConfig = false;
@@ -134,6 +144,17 @@ const formatContractLink = (contractId: string): string | null => {
return `${baseUrl.replace(/\/$/, "")}/contacts?contract=${contractId}`; 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 { export class EmailService {
static async sendContractAnalysisCompletedEmail( static async sendContractAnalysisCompletedEmail(
input: ContractAnalysisEmailInput, input: ContractAnalysisEmailInput,
@@ -178,6 +199,7 @@ export class EmailService {
input.blueprint.premiumCurrency, input.blueprint.premiumCurrency,
); );
const contractUrl = formatContractLink(input.contractId); const contractUrl = formatContractLink(input.contractId);
const logoUrl = getLogoUrl();
const blockchainStatus = input.blockchain const blockchainStatus = input.blockchain
? "Registered" ? "Registered"
: "Not registered (blockchain unavailable or skipped)"; : "Not registered (blockchain unavailable or skipped)";
@@ -185,7 +207,7 @@ export class EmailService {
const textBody = [ const textBody = [
`Hello ${recipientName},`, `Hello ${recipientName},`,
"", "",
"Your contract analysis is complete.", "Your LexiChain contract intelligence report is ready.",
"", "",
"Blueprint:", "Blueprint:",
`- Contract title: ${input.contractTitle}`, `- Contract title: ${input.contractTitle}`,
@@ -197,7 +219,7 @@ export class EmailService {
`- End date: ${formatDateValue(input.blueprint.endDate)}`, `- End date: ${formatDateValue(input.blueprint.endDate)}`,
`- Premium: ${premiumLabel}`, `- Premium: ${premiumLabel}`,
"", "",
"Summary:", "Executive summary:",
input.blueprint.summary, input.blueprint.summary,
"", "",
"Blockchain proof:", "Blockchain proof:",
@@ -212,46 +234,97 @@ export class EmailService {
"", "",
contractUrl ? `Open in app: ${contractUrl}` : "", contractUrl ? `Open in app: ${contractUrl}` : "",
"", "",
"Keep this email for your records.", "Thank you for trusting LexiChain for secure contract governance.",
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
const htmlBody = ` const htmlBody = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #0f172a;"> <div style="margin:0;padding:0;background:#0f172a;">
<h2 style="margin-bottom: 12px;">Contract Analysis Completed</h2> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#0f172a;padding:24px 0;">
<p>Hello ${recipientName},</p> <tr>
<p>Your contract analysis has been completed successfully.</p> <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> <tr>
<ul> <td style="padding:24px 28px;">
<li><strong>Contract title:</strong> ${input.contractTitle}</li> <p style="margin:0 0 16px;font-size:14px;">Hello ${recipientName},</p>
<li><strong>Original file:</strong> ${input.contractFileName}</li> <p style="margin:0 0 20px;font-size:14px;color:#334155;">
<li><strong>Type:</strong> ${input.blueprint.type}</li> Your LexiChain analysis is complete. Below is the executive blueprint and proof trace.
<li><strong>Provider:</strong> ${input.blueprint.provider ?? "N/A"}</li> </p>
<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>
<h3 style="margin-top: 24px; margin-bottom: 8px;">Summary</h3> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:16px;">
<p>${input.blueprint.summary.replace(/\n/g, "<br />")}</p> <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> <div style="margin:18px 0 8px;font-size:13px;font-weight:bold;">Executive Summary</div>
<ul> <p style="margin:0 0 18px;font-size:13px;color:#475569;line-height:1.6;">${input.blueprint.summary.replace(/\n/g, "<br />")}</p>
<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>
${contractUrl ? `<p><a href="${contractUrl}">Open this contract in your dashboard</a></p>` : ""} <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#eef2ff;border:1px solid #c7d2fe;border-radius:14px;padding:16px;">
<p style="margin-top: 24px; font-size: 12px; color: #475569;">Keep this email for your records.</p> <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> </div>
`; `;
@@ -259,11 +332,14 @@ export class EmailService {
from, from,
to: input.to, to: input.to,
subject: `Contract analyzed: ${input.contractTitle}`, subject: `Contract analyzed: ${input.contractTitle}`,
text: textBody,
html: htmlBody, 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) { if (previewUrl) {
console.log(`📨 Ethereal preview URL: ${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 { prisma } from "@/lib/db/prisma";
import { EmailService } from "@/lib/services/email.service";
let hasWarnedMissingNotificationTable = false; let hasWarnedMissingNotificationTable = false;
@@ -547,9 +548,9 @@ export class NotificationService {
* Checks for upcoming contract renewals/expirations and creates notifications * Checks for upcoming contract renewals/expirations and creates notifications
* *
* Scans all contracts for a user and creates DEADLINE notifications for: * Scans all contracts for a user and creates DEADLINE notifications for:
* - 30 days before expiration (CRITICAL) * - 30 days before expiration
* - 15 days before expiration (WARNING) * - 14 days before expiration
* - 7 days before expiration (URGENT) * - 7 days before expiration
* *
* @param userId - The user's ID * @param userId - The user's ID
* @returns Promise with count of created notifications * @returns Promise with count of created notifications
@@ -557,7 +558,7 @@ export class NotificationService {
* Steps: * Steps:
* 1. Query all COMPLETED contracts with endDate for the user * 1. Query all COMPLETED contracts with endDate for the user
* 2. Calculate days until expiration * 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 * 4. Check for existing notification to avoid duplicates
* 5. Return summary of created notifications * 5. Return summary of created notifications
* *
@@ -573,6 +574,15 @@ export class NotificationService {
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); 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 // Query all contracts with endDate for this user
const contracts = await prisma.contract.findMany({ const contracts = await prisma.contract.findMany({
where: { where: {
@@ -610,12 +620,12 @@ export class NotificationService {
if (daysUntilExpiration === 7) { if (daysUntilExpiration === 7) {
shouldNotify = true; shouldNotify = true;
level = "URGENT"; level = "URGENT";
} else if (daysUntilExpiration === 15) { } else if (daysUntilExpiration === 14) {
shouldNotify = true; shouldNotify = true;
level = "WARNING"; level = "WARNING";
} else if (daysUntilExpiration === 30) { } else if (daysUntilExpiration === 30) {
shouldNotify = true; shouldNotify = true;
level = "CRITICAL"; level = "NOTICE";
} }
if (shouldNotify) { if (shouldNotify) {
@@ -634,18 +644,21 @@ export class NotificationService {
// Only create if not already notified today // Only create if not already notified today
if (!existingNotification) { if (!existingNotification) {
const notificationTitle = const notificationTitle =
level === "CRITICAL" level === "NOTICE"
? `🔴 Contract Expiring in 30 Days` ? "Contract renewal reminder (30 days)"
: level === "WARNING" : level === "WARNING"
? `🟠 Contract Expiring in 15 Days` ? "Contract renewal window (14 days)"
: `🟡 Contract Expiring in 7 Days`; : "Contract deadline in 7 days";
const providerLabel = contract.provider || "your provider";
const contractLabel = contract.title || "your contract";
const notificationMessage = const notificationMessage =
level === "CRITICAL" level === "NOTICE"
? `${contract.title} from ${contract.provider} will expire on ${contractEnd.toLocaleDateString()}. Time to renew!` ? `${contractLabel} from ${providerLabel} will expire on ${contractEnd.toLocaleDateString()}. Plan your renewal.`
: level === "WARNING" : level === "WARNING"
? `${contract.title} from ${contract.provider} expires in 15 days. Consider scheduling renewal.` ? `${contractLabel} from ${providerLabel} expires in 14 days. Please review renewal steps.`
: `${contract.title} from ${contract.provider} expires in 7 days. Renew now!`; : `${contractLabel} from ${providerLabel} expires in 7 days. Action is required.`;
const result = await this.create({ const result = await this.create({
userId, userId,
@@ -654,7 +667,7 @@ export class NotificationService {
message: notificationMessage, message: notificationMessage,
contractId: contract.id, contractId: contract.id,
actionType: `RENEWAL_${level}`, actionType: `RENEWAL_${level}`,
icon: level === "CRITICAL" ? "AlertCircle" : "AlertTriangle", icon: level === "URGENT" ? "AlertCircle" : "AlertTriangle",
expiresIn: 24 * 60 * 60 * 1000, // 24 hours expiresIn: 24 * 60 * 60 * 1000, // 24 hours
actionData: { actionData: {
level, level,
@@ -667,6 +680,29 @@ export class NotificationService {
if (result.success) { if (result.success) {
createdNotifications.push(contract.id); 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,
);
}
}
} }
} }
} }

View File

@@ -1,7 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
serverExternalPackages: ["pdfkit"],
}; };
export default nextConfig; export default nextConfig;

470
package-lock.json generated
View File

@@ -58,7 +58,9 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.7", "nodemailer": "^8.0.7",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"pdfkit": "^0.16.0",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"qrcode": "^1.5.4",
"react": "19.2.3", "react": "19.2.3",
"react-day-picker": "^9.13.2", "react-day-picker": "^9.13.2",
"react-dom": "19.2.3", "react-dom": "19.2.3",
@@ -83,6 +85,9 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5" "typescript": "^5"
},
"engines": {
"node": ">=20 <23"
} }
}, },
"node_modules/@adraffy/ens-normalize": { "node_modules/@adraffy/ens-normalize": {
@@ -4402,6 +4407,30 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-promise": { "node_modules/any-promise": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -4722,6 +4751,26 @@
"node": ">= 0.6.0" "node": ">= 0.6.0"
} }
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -4773,6 +4822,30 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserify-zlib/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.1", "version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -4897,6 +4970,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": { "node_modules/camelcase-css": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -4989,6 +5071,26 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -5014,6 +5116,24 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc" "react-dom": "^18 || ^19 || ^19.0.0-rc"
} }
}, },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": { "node_modules/commander": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -5080,6 +5200,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/css-line-break": { "node_modules/css-line-break": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
@@ -5325,6 +5451,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js-light": { "node_modules/decimal.js-light": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -5420,6 +5555,12 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/didyoumean": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -5427,6 +5568,12 @@
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": { "node_modules/dlv": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -6288,7 +6435,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-glob": { "node_modules/fast-glob": {
@@ -6450,6 +6596,23 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -6582,6 +6745,15 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -7122,6 +7294,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": { "node_modules/is-generator-function": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -7394,6 +7575,13 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jpeg-exif": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/js-cookie": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@@ -7556,6 +7744,25 @@
"url": "https://github.com/sponsors/antonk52" "url": "https://github.com/sponsors/antonk52"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -8196,6 +8403,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pako": { "node_modules/pako": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
@@ -8206,7 +8422,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -8267,6 +8482,19 @@
"@napi-rs/canvas": "^0.1.80" "@napi-rs/canvas": "^0.1.80"
} }
}, },
"node_modules/pdfkit": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.16.0.tgz",
"integrity": "sha512-oXMxkIqXH4uTAtohWdYA41i/f6i2ReB78uhgizN8H4hJEpgR3/Xjy3iu2InNAuwCIabN3PVs8P1D6G4+W2NH0A==",
"license": "MIT",
"dependencies": {
"crypto-js": "^4.2.0",
"fontkit": "^2.0.4",
"jpeg-exif": "^1.1.4",
"linebreak": "^1.1.0",
"png-js": "^1.0.0"
}
},
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -8330,6 +8558,23 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -8583,6 +8828,23 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -8910,6 +9172,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/reselect": { "node_modules/reselect": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@@ -8947,6 +9224,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -9069,6 +9352,12 @@
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -9347,6 +9636,26 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/string.prototype.includes": { "node_modules/string.prototype.includes": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -9460,6 +9769,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-bom": { "node_modules/strip-bom": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -9714,6 +10035,12 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tiny-invariant": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -9983,6 +10310,32 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@@ -10303,6 +10656,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.20", "version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@@ -10335,6 +10694,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": { "node_modules/ws": {
"version": "8.17.1", "version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
@@ -10356,6 +10729,12 @@
} }
} }
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -10363,6 +10742,93 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -2,8 +2,13 @@
"name": "bfsi-project", "name": "bfsi-project",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"engines": {
"node": ">=20 <23"
},
"scripts": { "scripts": {
"dev": "next dev", "dev": "node scripts/dev-with-chain.mjs",
"dev:next": "next dev",
"dev:with-chain": "node scripts/dev-with-chain.mjs",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
@@ -58,8 +63,10 @@
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.7", "nodemailer": "^8.0.7",
"pdfkit": "^0.16.0",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"qrcode": "^1.5.4",
"react": "19.2.3", "react": "19.2.3",
"react-day-picker": "^9.13.2", "react-day-picker": "^9.13.2",
"react-dom": "19.2.3", "react-dom": "19.2.3",

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

247
scripts/dev-with-chain.mjs Normal file
View File

@@ -0,0 +1,247 @@
import { spawn } from "child_process";
import { fileURLToPath } from "url";
import path from "path";
import process from "process";
import { readFile, writeFile } from "fs/promises";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, "..");
const blockchainDir = path.join(rootDir, "blockchain");
const rpcUrl = "http://127.0.0.1:8545";
const defaultDevPrivateKey =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const bootstrapFlag = "__LEXICHAIN_CHAIN_BOOTSTRAPPED";
const deployStatePath = path.join(blockchainDir, ".dev-deploy.json");
function spawnProcess(command, args, options) {
return spawn(command, args, {
shell: true,
stdio: "inherit",
...options,
});
}
async function waitForRpc(timeoutMs) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const response = await fetch(rpcUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_blockNumber",
params: [],
}),
});
if (response.ok) return;
} catch {
// Ignore transient connection failures while the node starts.
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error("Hardhat RPC did not become ready in time.");
}
async function isRpcReady() {
try {
const response = await fetch(rpcUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_blockNumber",
params: [],
}),
});
return response.ok;
} catch {
return false;
}
}
function isHexAddress(value) {
return /^0x[a-fA-F0-9]{40}$/.test(value);
}
async function hasContractCode(address) {
if (!isHexAddress(address)) return false;
try {
const response = await fetch(rpcUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_getCode",
params: [address, "latest"],
}),
});
if (!response.ok) return false;
const payload = await response.json();
const code = typeof payload?.result === "string" ? payload.result : "0x";
return code !== "0x";
} catch {
return false;
}
}
async function readSavedAddress() {
try {
const raw = await readFile(deployStatePath, "utf8");
const parsed = JSON.parse(raw);
const address = parsed?.address;
return isHexAddress(address) ? address : "";
} catch {
return "";
}
}
async function saveAddress(address) {
try {
await writeFile(
deployStatePath,
JSON.stringify({ address }, null, 2),
"utf8",
);
} catch {
// Non-fatal in dev.
}
}
async function resolveContractAddress() {
const candidates = [];
if (process.env.BLOCKCHAIN_CONTRACT_ADDRESS) {
candidates.push(process.env.BLOCKCHAIN_CONTRACT_ADDRESS);
}
const savedAddress = await readSavedAddress();
if (savedAddress) candidates.push(savedAddress);
const checked = new Set();
for (const candidate of candidates) {
if (!candidate || checked.has(candidate)) continue;
checked.add(candidate);
if (await hasContractCode(candidate)) {
console.log(`Using existing deployed contract: ${candidate}`);
return candidate;
}
}
console.log("Deploying contract to localhost...");
const deployedAddress = await runDeploy();
await saveAddress(deployedAddress);
return deployedAddress;
}
function runDeploy() {
return new Promise((resolve, reject) => {
const deploy = spawn(
"npx",
["hardhat", "run", "scripts/deploy.ts", "--network", "localhost"],
{
cwd: blockchainDir,
shell: true,
stdio: ["inherit", "pipe", "pipe"],
env: process.env,
},
);
let output = "";
deploy.stdout.on("data", (chunk) => {
const text = chunk.toString();
output += text;
process.stdout.write(text);
});
deploy.stderr.on("data", (chunk) => {
const text = chunk.toString();
output += text;
process.stderr.write(text);
});
deploy.on("close", (code) => {
if (code !== 0) {
reject(new Error("Hardhat deploy failed."));
return;
}
const addressMatch =
output.match(/BLOCKCHAIN_CONTRACT_ADDRESS=(0x[a-fA-F0-9]{40})/) ||
output.match(/deployed to:\s*(0x[a-fA-F0-9]{40})/i);
if (!addressMatch) {
reject(new Error("Could not detect deployed contract address."));
return;
}
resolve(addressMatch[1]);
});
});
}
let nodeProcess;
let nextProcess;
let startedNodeHere = false;
function shutdown(code) {
if (nextProcess && !nextProcess.killed) {
nextProcess.kill("SIGINT");
}
if (startedNodeHere && nodeProcess && !nodeProcess.killed) {
nodeProcess.kill("SIGINT");
}
process.exit(code);
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
(async () => {
const rpcReady = await isRpcReady();
if (rpcReady) {
console.log("Detected existing Hardhat node on 8545. Reusing it.");
} else {
console.log("Starting Hardhat node...");
startedNodeHere = true;
nodeProcess = spawnProcess("npx", ["hardhat", "node"], {
cwd: blockchainDir,
});
await waitForRpc(30000);
}
const contractAddress = await resolveContractAddress();
console.log(`Using contract address: ${contractAddress}`);
const env = {
...process.env,
BLOCKCHAIN_NETWORK: "hardhat",
BLOCKCHAIN_RPC_URL: rpcUrl,
BLOCKCHAIN_CONTRACT_ADDRESS: contractAddress,
BLOCKCHAIN_PRIVATE_KEY:
process.env.BLOCKCHAIN_PRIVATE_KEY || defaultDevPrivateKey,
[bootstrapFlag]: "1",
};
if (process.env[bootstrapFlag] === "1") {
throw new Error(
"Recursive dev bootstrap detected. Use npm run dev:next inside the bootstrap script.",
);
}
console.log("Starting Next.js dev server...");
nextProcess = spawn("npm", ["run", "dev:next"], {
cwd: rootDir,
shell: true,
stdio: "inherit",
env,
});
nextProcess.on("close", (code) => shutdown(code ?? 0));
})().catch((error) => {
console.error(error instanceof Error ? error.message : error);
shutdown(1);
});