Readme
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal 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
247
README.md
@@ -1,110 +1,215 @@
|
||||
# 🔗 LexiChain: Professional BFSI Document Intelligence Platform
|
||||
# LexiChain
|
||||
|
||||
> **Status**: Production-Ready PFE Project
|
||||
> **Target Audience**: Banking, Financial Services, and Insurance (BFSI) Institutions
|
||||
> **Key Innovation**: Hybrid integration of Generative AI (Gemini) and Ethereum Blockchain (DocumentRegistry)
|
||||
<p align="center">
|
||||
<img src="public/LexiChain.png" alt="LexiChain logo" width="180" />
|
||||
</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.
|
||||
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.
|
||||
## Product Highlights
|
||||
|
||||
---
|
||||
- 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 |
|
||||
| ------------------------------------------------ | ----------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
- **Smart Ingestion**: Uses high-fidelity OCR to read digital PDFs and scanned images.
|
||||
- **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.
|
||||
- **Intelligent Validation**: The AI automatically flags missing signatures, inconsistent dates, or high-risk clauses before the document is finalized.
|
||||
| Login | Register |
|
||||
| ---------------------------------------------- | ---------------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
### ⛓️ B. The Blockchain "Notary" Module (Security Layer)
|
||||
| Contracts |
|
||||
| ---------------------------------------------------- |
|
||||
|  |
|
||||
|
||||
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.
|
||||
- **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.
|
||||
LexiChain follows a practical feature-oriented structure:
|
||||
|
||||
---
|
||||
- [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.
|
||||
- **Benefits**: Decouples the Blockchain logic from the AI logic, making the system highly maintainable and ready for enterprise scaling.
|
||||
| Area | Stack |
|
||||
| -------------- | ------------------------------------ |
|
||||
| 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 |
|
||||
| :------------- | :----------------------- | :-------------------------------------------------------------------------------------------------- |
|
||||
| **Frontend** | Next.js 15 (React) | High performance, SEO-friendly, and supports React Server Components. |
|
||||
| **Backend** | Server Actions (Next.js) | Allows for secure, server-side blockchain signing and API communication without a separate backend.
|
||||
| **LLM** | Gemini | Unbeatable speed and context window size for long legal documents. |
|
||||
| **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. |
|
||||
- Node.js 20 or later
|
||||
- npm
|
||||
- PostgreSQL database
|
||||
- a local or remote blockchain endpoint for development and deployment
|
||||
- environment variables for Clerk, Gemini, database, and blockchain access
|
||||
|
||||
---
|
||||
## 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.
|
||||
- **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.
|
||||
Keep secrets in environment variables and never commit them to the repository.
|
||||
|
||||
---
|
||||
## 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**.
|
||||
- **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.
|
||||
Then install the blockchain workspace dependencies:
|
||||
|
||||
---
|
||||
```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.
|
||||
2. **Blockchain Innovation**: Implementing a production-ready Document Registry that solves real-world legal issues.
|
||||
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.
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
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.
|
||||
- **RAG (Retrieval-Augmented Generation)**: A technique that allows AI to answer questions based _only_ on the provided documents, preventing "hallucinations."
|
||||
- **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.
|
||||
- **Sepolia**: The public test network used to simulate the real Ethereum blockchain.
|
||||
```bash
|
||||
npm run dev:next
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
### 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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3
blockchain/.dev-deploy.json
Normal file
3
blockchain/.dev-deploy.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"address": "0x5FbDB2315678afecb367f032d93F642f64180aa3"
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
"name": "lexichain-blockchain",
|
||||
"version": "1.0.0",
|
||||
"description": "LexiChain Document Registry - Solidity Smart Contract",
|
||||
"engines": {
|
||||
"node": ">=20 <23"
|
||||
},
|
||||
"scripts": {
|
||||
"compile": "hardhat compile",
|
||||
"test": "hardhat test",
|
||||
|
||||
103
docs/DEPLOYMENT_OPENSTACK.md
Normal file
103
docs/DEPLOYMENT_OPENSTACK.md
Normal 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
|
||||
```
|
||||
@@ -19,7 +19,12 @@ import { prisma } from "@/lib/db/prisma";
|
||||
import { BlockchainService } from "@/lib/services/blockchain.service";
|
||||
import { NotificationService } from "@/lib/services/notification.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.
|
||||
@@ -44,7 +49,8 @@ export async function registerContractOnBlockchain(contractId: string) {
|
||||
if (!BlockchainService.isConfigured()) {
|
||||
return {
|
||||
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
|
||||
const proof = await BlockchainService.hashAndRegister(
|
||||
contract.fileUrl,
|
||||
contract.fileName
|
||||
contract.fileName,
|
||||
);
|
||||
|
||||
// Save proof data to the Contract record
|
||||
@@ -137,7 +143,8 @@ export async function registerContractOnBlockchain(contractId: string) {
|
||||
console.error("❌ Blockchain registration error:", error);
|
||||
return {
|
||||
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 (!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);
|
||||
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" };
|
||||
if (contract.userId !== user.id)
|
||||
return { success: false, error: "Unauthorized" };
|
||||
|
||||
if (!contract.documentHash) {
|
||||
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 {
|
||||
success: true,
|
||||
@@ -199,7 +212,10 @@ export async function verifyDocumentHashOnBlockchain(documentHash: string) {
|
||||
if (!clerkId) return { success: false, error: "Unauthorized" };
|
||||
|
||||
if (!BlockchainService.isReadConfigured()) {
|
||||
return { success: false, error: "Blockchain not configured for verification" };
|
||||
return {
|
||||
success: false,
|
||||
error: "Blockchain not configured for verification",
|
||||
};
|
||||
}
|
||||
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
116
features/contracts/components/contract-countdown.tsx
Normal file
116
features/contracts/components/contract-countdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { ContractChatModal } from "@/features/contracts/components/modals/contract-chat-modal";
|
||||
import { ContractProofModal } from "@/features/contracts/components/modals/contract-proof-modal";
|
||||
import { ContractCountdown } from "@/features/contracts/components/contract-countdown";
|
||||
import {
|
||||
stripMarkdown,
|
||||
exportToCSV,
|
||||
@@ -1122,6 +1123,10 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
/>
|
||||
{status.label}
|
||||
</span>
|
||||
{contract.endDate &&
|
||||
contract.status === "COMPLETED" && (
|
||||
<ContractCountdown endDate={contract.endDate} />
|
||||
)}
|
||||
{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">
|
||||
<Network className="h-3 w-3" />
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
|
||||
import { ethers } from "ethers";
|
||||
import { createHash } from "crypto";
|
||||
import type { BlockchainProof, BlockchainVerification, BlockchainStats } from "./blockchain.types";
|
||||
import type {
|
||||
BlockchainProof,
|
||||
BlockchainVerification,
|
||||
BlockchainStats,
|
||||
} from "./blockchain.types";
|
||||
|
||||
// ─────────────────────────────────────────────────
|
||||
// Smart Contract ABI (Application Binary Interface)
|
||||
@@ -102,7 +106,7 @@ function getReadContract(): ethers.Contract {
|
||||
_readContract = new ethers.Contract(
|
||||
contractAddress,
|
||||
DOCUMENT_REGISTRY_ABI,
|
||||
getProvider()
|
||||
getProvider(),
|
||||
);
|
||||
}
|
||||
return _readContract;
|
||||
@@ -117,7 +121,7 @@ function getWriteContract(): ethers.Contract {
|
||||
_writeContract = new ethers.Contract(
|
||||
contractAddress,
|
||||
DOCUMENT_REGISTRY_ABI,
|
||||
getWallet()
|
||||
getWallet(),
|
||||
);
|
||||
}
|
||||
return _writeContract;
|
||||
@@ -214,7 +218,7 @@ export class BlockchainService {
|
||||
*/
|
||||
static async registerOnChain(
|
||||
documentHash: string,
|
||||
fileName: string
|
||||
fileName: string,
|
||||
): Promise<BlockchainProof> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error("Blockchain not configured. Check your .env variables.");
|
||||
@@ -307,7 +311,7 @@ export class BlockchainService {
|
||||
* @returns Verification result with existence, timestamp, depositor
|
||||
*/
|
||||
static async verifyOnChain(
|
||||
documentHash: string
|
||||
documentHash: string,
|
||||
): Promise<BlockchainVerification> {
|
||||
if (!this.isReadConfigured()) {
|
||||
throw new Error("Blockchain read access not configured");
|
||||
@@ -331,7 +335,7 @@ export class BlockchainService {
|
||||
*/
|
||||
static async hashAndRegister(
|
||||
fileUrl: string,
|
||||
fileName: string
|
||||
fileName: string,
|
||||
): Promise<BlockchainProof> {
|
||||
const documentHash = await this.hashDocument(fileUrl);
|
||||
return await this.registerOnChain(documentHash, fileName);
|
||||
@@ -368,13 +372,14 @@ export class BlockchainService {
|
||||
const [blockNumber, totalDocs, networkObj] = await Promise.all([
|
||||
provider.getBlockNumber(),
|
||||
contract.totalDocuments(),
|
||||
provider.getNetwork()
|
||||
provider.getNetwork(),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalVerified: Number(totalDocs),
|
||||
latestBlockNumber: blockNumber,
|
||||
networkName: config.network === "sepolia" ? "Ethereum Sepolia" : "Hardhat Local",
|
||||
networkName:
|
||||
config.network === "sepolia" ? "Ethereum Sepolia" : "Hardhat Local",
|
||||
networkStatus: "connected",
|
||||
walletAddress,
|
||||
chainId: Number(networkObj.chainId),
|
||||
@@ -384,7 +389,8 @@ export class BlockchainService {
|
||||
return {
|
||||
totalVerified: 0,
|
||||
latestBlockNumber: null,
|
||||
networkName: config.network === "sepolia" ? "Ethereum Sepolia" : "Hardhat Local",
|
||||
networkName:
|
||||
config.network === "sepolia" ? "Ethereum Sepolia" : "Hardhat Local",
|
||||
networkStatus: "disconnected",
|
||||
walletAddress: "",
|
||||
};
|
||||
|
||||
@@ -48,6 +48,37 @@ export interface BlockchainTransactionView {
|
||||
explorerUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cryptographic certificate for a registered contract.
|
||||
* Contains metadata, blockchain proof, and a digital signature.
|
||||
* Can be downloaded and verified by third parties.
|
||||
*/
|
||||
export interface CryptographicCertificate {
|
||||
// Document metadata
|
||||
contractId: string;
|
||||
contractTitle: string | null;
|
||||
contractFileName: string;
|
||||
documentHash: string;
|
||||
|
||||
// Blockchain proof
|
||||
txHash: string;
|
||||
blockNumber: number;
|
||||
blockTimestamp: string; // ISO string
|
||||
network: string;
|
||||
contractAddress: string;
|
||||
|
||||
// Certificate metadata
|
||||
certificateId: string; // Unique certificate identifier
|
||||
issuedAt: string; // ISO string - when the certificate was generated
|
||||
issuer: string; // Ethereum address that issued the certificate (server wallet)
|
||||
|
||||
// Digital signature (ECDSA)
|
||||
signature: string; // Signed message in hex format (0x...)
|
||||
|
||||
// Version for future compatibility
|
||||
version: "1.0";
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats displayed at the top of the blockchain explorer page.
|
||||
*/
|
||||
|
||||
402
lib/services/certificate.service.ts
Normal file
402
lib/services/certificate.service.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Certificate Service
|
||||
*
|
||||
* Generates cryptographically signed certificates for blockchain-registered contracts.
|
||||
* These certificates can be downloaded and verified by third parties, proving that
|
||||
* a contract was registered on-chain with specific metadata at a specific timestamp.
|
||||
*
|
||||
* Uses ECDSA (Elliptic Curve Digital Signature Algorithm) via ethers.js Wallet.
|
||||
*/
|
||||
|
||||
import { ethers } from "ethers";
|
||||
import { createHash } from "crypto";
|
||||
import PDFDocument from "pdfkit";
|
||||
import QRCode from "qrcode";
|
||||
import type { CryptographicCertificate } from "./blockchain.types";
|
||||
import { BlockchainService } from "./blockchain.service";
|
||||
|
||||
export class CertificateService {
|
||||
/**
|
||||
* Generate a digitally-signed certificate for a blockchain-registered contract.
|
||||
*
|
||||
* @param contractId - Contract ID
|
||||
* @param contractTitle - Contract title
|
||||
* @param contractFileName - Original file name
|
||||
* @param documentHash - SHA-256 hash of the document
|
||||
* @param txHash - Blockchain transaction hash
|
||||
* @param blockNumber - Block number
|
||||
* @param blockTimestamp - Block timestamp (ISO string)
|
||||
* @param network - Blockchain network ('hardhat' | 'sepolia')
|
||||
* @param contractAddress - Smart contract address
|
||||
* @returns Digitally-signed certificate
|
||||
*/
|
||||
static async generateCertificate(
|
||||
contractId: string,
|
||||
contractTitle: string | null,
|
||||
contractFileName: string,
|
||||
documentHash: string,
|
||||
txHash: string,
|
||||
blockNumber: number,
|
||||
blockTimestamp: string,
|
||||
network: string,
|
||||
contractAddress: string,
|
||||
): Promise<CryptographicCertificate> {
|
||||
if (!BlockchainService.isReadConfigured()) {
|
||||
throw new Error("Blockchain not configured for certificate generation");
|
||||
}
|
||||
|
||||
// Generate unique certificate ID
|
||||
const certificateId = this.generateCertificateId(contractId);
|
||||
|
||||
// Get issuer wallet address
|
||||
let issuerAddress = "";
|
||||
try {
|
||||
// Try to get the wallet address if blockchain write is configured
|
||||
if (BlockchainService.isConfigured()) {
|
||||
// We can't directly access the wallet from here, so we'll use the private key env var
|
||||
const privateKey = process.env.BLOCKCHAIN_PRIVATE_KEY;
|
||||
if (privateKey) {
|
||||
const wallet = new ethers.Wallet(privateKey);
|
||||
issuerAddress = wallet.address;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If wallet creation fails, use a placeholder
|
||||
issuerAddress = "0x0000000000000000000000000000000000000000";
|
||||
}
|
||||
|
||||
const issuedAt = new Date().toISOString();
|
||||
|
||||
// Prepare the data to sign
|
||||
const certificateData = {
|
||||
contractId,
|
||||
contractTitle,
|
||||
contractFileName,
|
||||
documentHash,
|
||||
txHash,
|
||||
blockNumber,
|
||||
blockTimestamp,
|
||||
network,
|
||||
contractAddress,
|
||||
certificateId,
|
||||
issuedAt,
|
||||
issuer: issuerAddress,
|
||||
version: "1.0",
|
||||
};
|
||||
|
||||
// Create a message to sign (hash of the certificate data)
|
||||
const messageToSign = this.createSignableMessage(certificateData);
|
||||
const messageHash = ethers.hashMessage(messageToSign);
|
||||
|
||||
let signature = "";
|
||||
try {
|
||||
// Sign the message with the server wallet
|
||||
if (BlockchainService.isConfigured()) {
|
||||
const privateKey = process.env.BLOCKCHAIN_PRIVATE_KEY;
|
||||
if (privateKey) {
|
||||
const wallet = new ethers.Wallet(privateKey);
|
||||
signature = await wallet.signMessage(messageToSign);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to sign certificate:", error);
|
||||
signature = "0x"; // Return empty signature if signing fails
|
||||
}
|
||||
|
||||
const certificate: CryptographicCertificate = {
|
||||
contractId,
|
||||
contractTitle,
|
||||
contractFileName,
|
||||
documentHash,
|
||||
txHash,
|
||||
blockNumber,
|
||||
blockTimestamp,
|
||||
network,
|
||||
contractAddress,
|
||||
certificateId,
|
||||
issuedAt,
|
||||
issuer: issuerAddress,
|
||||
signature,
|
||||
version: "1.0",
|
||||
};
|
||||
|
||||
return certificate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique certificate ID based on contract ID and timestamp.
|
||||
*/
|
||||
private static generateCertificateId(contractId: string): string {
|
||||
const timestamp = Date.now();
|
||||
const combined = `${contractId}:${timestamp}`;
|
||||
const hash = createHash("sha256").update(combined).digest("hex");
|
||||
return `cert_${hash.slice(0, 16)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a human-readable and machine-verifiable message to sign.
|
||||
*/
|
||||
private static createSignableMessage(data: any): string {
|
||||
const lines = [
|
||||
"═══════════════════════════════════════════",
|
||||
"LexiChain Cryptographic Certificate",
|
||||
"═══════════════════════════════════════════",
|
||||
"",
|
||||
`Certificate ID: ${data.certificateId}`,
|
||||
`Contract: ${data.contractTitle || data.contractFileName}`,
|
||||
`Document Hash: ${data.documentHash}`,
|
||||
"",
|
||||
"Blockchain Proof:",
|
||||
` Transaction Hash: ${data.txHash}`,
|
||||
` Block Number: ${data.blockNumber}`,
|
||||
` Timestamp: ${data.blockTimestamp}`,
|
||||
` Network: ${data.network}`,
|
||||
` Contract Address: ${data.contractAddress}`,
|
||||
"",
|
||||
`Issued At: ${data.issuedAt}`,
|
||||
`Issued By: ${data.issuer}`,
|
||||
"",
|
||||
"This certificate cryptographically proves that the above",
|
||||
"document was registered on the blockchain at the specified",
|
||||
"block and timestamp. The signature on this message can be",
|
||||
"verified using the issuer's public key.",
|
||||
"═══════════════════════════════════════════",
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a certificate's signature (useful for third parties).
|
||||
* Returns true if the signature is valid and matches the issuer.
|
||||
*/
|
||||
static verifyCertificateSignature(
|
||||
certificate: CryptographicCertificate,
|
||||
): boolean {
|
||||
try {
|
||||
const messageToSign = this.createSignableMessage(certificate);
|
||||
const recoveredAddress = ethers.verifyMessage(
|
||||
messageToSign,
|
||||
certificate.signature,
|
||||
);
|
||||
return (
|
||||
recoveredAddress.toLowerCase() === certificate.issuer.toLowerCase()
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a professional PDF certificate for the given certificate data.
|
||||
*/
|
||||
static async generateCertificatePdf(
|
||||
certificate: CryptographicCertificate,
|
||||
): Promise<Buffer> {
|
||||
const qrPayload = JSON.stringify({
|
||||
certificateId: certificate.certificateId,
|
||||
contractId: certificate.contractId,
|
||||
documentHash: certificate.documentHash,
|
||||
txHash: certificate.txHash,
|
||||
blockNumber: certificate.blockNumber,
|
||||
blockTimestamp: certificate.blockTimestamp,
|
||||
network: certificate.network,
|
||||
contractAddress: certificate.contractAddress,
|
||||
issuer: certificate.issuer,
|
||||
issuedAt: certificate.issuedAt,
|
||||
version: certificate.version,
|
||||
});
|
||||
|
||||
const qrPng = await QRCode.toBuffer(qrPayload, {
|
||||
width: 160,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: "M",
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const doc = new PDFDocument({ size: "A4", margin: 56 });
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
doc.on("data", (chunk) => chunks.push(chunk as Buffer));
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
|
||||
// Header
|
||||
doc
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(20)
|
||||
.fillColor("#111827")
|
||||
.text("LexiChain Contract Authenticity Certificate", {
|
||||
align: "center",
|
||||
})
|
||||
.moveDown(0.2);
|
||||
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(10)
|
||||
.fillColor("#6B7280")
|
||||
.text("Digitally signed proof of blockchain registration", {
|
||||
align: "center",
|
||||
})
|
||||
.moveDown(1.2);
|
||||
|
||||
// Divider
|
||||
doc
|
||||
.moveTo(doc.page.margins.left, doc.y)
|
||||
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
|
||||
.lineWidth(1)
|
||||
.strokeColor("#E5E7EB")
|
||||
.stroke()
|
||||
.moveDown(0.8);
|
||||
|
||||
const pageLeft = doc.page.margins.left;
|
||||
const pageRight = doc.page.width - doc.page.margins.right;
|
||||
const gap = 24;
|
||||
const qrSize = 120;
|
||||
const rightX = pageRight - qrSize;
|
||||
const leftWidth = pageRight - pageLeft - qrSize - gap;
|
||||
const sectionTitle = (title: string) => {
|
||||
doc
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.fillColor("#111827")
|
||||
.text(title, pageLeft, doc.y)
|
||||
.moveDown(0.4);
|
||||
};
|
||||
|
||||
const writeFieldAt = (
|
||||
x: number,
|
||||
width: number,
|
||||
label: string,
|
||||
value: string,
|
||||
) => {
|
||||
doc
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(10)
|
||||
.fillColor("#111827")
|
||||
.text(`${label} `, x, doc.y, { continued: true, width });
|
||||
doc.font("Helvetica").fillColor("#111827").text(value, { width });
|
||||
doc.moveDown(0.3);
|
||||
};
|
||||
|
||||
// Summary + QR
|
||||
const summaryStartY = doc.y + 6;
|
||||
doc.image(qrPng, rightX, summaryStartY, {
|
||||
width: qrSize,
|
||||
height: qrSize,
|
||||
});
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(8)
|
||||
.fillColor("#6B7280")
|
||||
.text("Verification QR", rightX, summaryStartY + qrSize + 4, {
|
||||
width: qrSize,
|
||||
align: "center",
|
||||
});
|
||||
|
||||
doc.y = summaryStartY;
|
||||
doc
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.fillColor("#111827")
|
||||
.text("Certificate Summary", pageLeft, doc.y, { width: leftWidth })
|
||||
.moveDown(0.4);
|
||||
|
||||
writeFieldAt(
|
||||
pageLeft,
|
||||
leftWidth,
|
||||
"Certificate ID:",
|
||||
certificate.certificateId,
|
||||
);
|
||||
writeFieldAt(
|
||||
pageLeft,
|
||||
leftWidth,
|
||||
"Contract:",
|
||||
certificate.contractTitle || certificate.contractFileName,
|
||||
);
|
||||
writeFieldAt(pageLeft, leftWidth, "Issued At:", certificate.issuedAt);
|
||||
writeFieldAt(pageLeft, leftWidth, "Network:", certificate.network);
|
||||
|
||||
doc.y = Math.max(doc.y, summaryStartY + qrSize + 24);
|
||||
doc.moveDown(0.4);
|
||||
|
||||
// Document Proof
|
||||
sectionTitle("Document Proof");
|
||||
doc.font("Helvetica-Bold").fontSize(10).text("Document Hash:");
|
||||
doc
|
||||
.font("Courier")
|
||||
.fontSize(9)
|
||||
.fillColor("#111827")
|
||||
.text(certificate.documentHash, { lineGap: 2 })
|
||||
.moveDown(0.6);
|
||||
|
||||
// Blockchain Proof
|
||||
sectionTitle("Blockchain Proof");
|
||||
doc.font("Helvetica-Bold").fontSize(10).text("Transaction Hash:");
|
||||
doc
|
||||
.font("Courier")
|
||||
.fontSize(9)
|
||||
.fillColor("#111827")
|
||||
.text(certificate.txHash, { lineGap: 2 })
|
||||
.moveDown(0.4);
|
||||
|
||||
writeFieldAt(
|
||||
pageLeft,
|
||||
pageRight - pageLeft,
|
||||
"Block Number:",
|
||||
String(certificate.blockNumber),
|
||||
);
|
||||
writeFieldAt(
|
||||
pageLeft,
|
||||
pageRight - pageLeft,
|
||||
"Block Timestamp:",
|
||||
certificate.blockTimestamp,
|
||||
);
|
||||
writeFieldAt(
|
||||
pageLeft,
|
||||
pageRight - pageLeft,
|
||||
"Contract Address:",
|
||||
certificate.contractAddress,
|
||||
);
|
||||
|
||||
// Issuer and Signature
|
||||
doc.moveDown(0.2);
|
||||
sectionTitle("Issuer and Signature");
|
||||
writeFieldAt(
|
||||
pageLeft,
|
||||
pageRight - pageLeft,
|
||||
"Issued By:",
|
||||
certificate.issuer,
|
||||
);
|
||||
|
||||
doc.font("Helvetica-Bold").fontSize(10).text("Digital Signature:");
|
||||
doc
|
||||
.font("Courier")
|
||||
.fontSize(8.5)
|
||||
.fillColor("#111827")
|
||||
.text(certificate.signature || "Signature not available", {
|
||||
lineGap: 2,
|
||||
})
|
||||
.moveDown(0.6);
|
||||
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(9)
|
||||
.fillColor("#374151")
|
||||
.text(
|
||||
"This certificate verifies that the document referenced above was registered on the blockchain and can be independently verified using the issuer address and signature.",
|
||||
{ align: "left" },
|
||||
)
|
||||
.moveDown(0.4);
|
||||
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(8)
|
||||
.fillColor("#6B7280")
|
||||
.text("LexiChain - Confidential and tamper-evident certification.", {
|
||||
align: "center",
|
||||
});
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,16 @@ interface ContractAnalysisEmailInput {
|
||||
blockchain?: BlockchainEmailData | null;
|
||||
}
|
||||
|
||||
interface ContractDeadlineEmailInput {
|
||||
to: string;
|
||||
userDisplayName?: string | null;
|
||||
contractId: string;
|
||||
contractTitle: string | null;
|
||||
contractProvider: string | null;
|
||||
contractEndDate: Date;
|
||||
daysUntilExpiration: number;
|
||||
}
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
let transportMode: "smtp" | "ethereal" | null = null;
|
||||
let hasWarnedMissingEmailConfig = false;
|
||||
@@ -134,6 +144,17 @@ const formatContractLink = (contractId: string): string | null => {
|
||||
return `${baseUrl.replace(/\/$/, "")}/contacts?contract=${contractId}`;
|
||||
};
|
||||
|
||||
const getBaseUrl = (): string | null => {
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.APP_URL?.trim();
|
||||
return baseUrl ? baseUrl.replace(/\/$/, "") : null;
|
||||
};
|
||||
|
||||
const getLogoUrl = (): string | null => {
|
||||
const baseUrl = getBaseUrl();
|
||||
return baseUrl ? `${baseUrl}/LexiChain.png` : null;
|
||||
};
|
||||
|
||||
export class EmailService {
|
||||
static async sendContractAnalysisCompletedEmail(
|
||||
input: ContractAnalysisEmailInput,
|
||||
@@ -178,6 +199,7 @@ export class EmailService {
|
||||
input.blueprint.premiumCurrency,
|
||||
);
|
||||
const contractUrl = formatContractLink(input.contractId);
|
||||
const logoUrl = getLogoUrl();
|
||||
const blockchainStatus = input.blockchain
|
||||
? "Registered"
|
||||
: "Not registered (blockchain unavailable or skipped)";
|
||||
@@ -185,7 +207,7 @@ export class EmailService {
|
||||
const textBody = [
|
||||
`Hello ${recipientName},`,
|
||||
"",
|
||||
"Your contract analysis is complete.",
|
||||
"Your LexiChain contract intelligence report is ready.",
|
||||
"",
|
||||
"Blueprint:",
|
||||
`- Contract title: ${input.contractTitle}`,
|
||||
@@ -197,7 +219,7 @@ export class EmailService {
|
||||
`- End date: ${formatDateValue(input.blueprint.endDate)}`,
|
||||
`- Premium: ${premiumLabel}`,
|
||||
"",
|
||||
"Summary:",
|
||||
"Executive summary:",
|
||||
input.blueprint.summary,
|
||||
"",
|
||||
"Blockchain proof:",
|
||||
@@ -212,46 +234,97 @@ export class EmailService {
|
||||
"",
|
||||
contractUrl ? `Open in app: ${contractUrl}` : "",
|
||||
"",
|
||||
"Keep this email for your records.",
|
||||
"Thank you for trusting LexiChain for secure contract governance.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #0f172a;">
|
||||
<h2 style="margin-bottom: 12px;">Contract Analysis Completed</h2>
|
||||
<p>Hello ${recipientName},</p>
|
||||
<p>Your contract analysis has been completed successfully.</p>
|
||||
<div style="margin:0;padding:0;background:#0f172a;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#0f172a;padding:24px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="background:#ffffff;border-radius:20px;overflow:hidden;font-family:Arial, sans-serif;color:#0f172a;">
|
||||
<tr>
|
||||
<td style="padding:24px 28px;background:linear-gradient(135deg,#0f172a 0%,#1d4ed8 100%);color:#ffffff;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="left" style="vertical-align:middle;">
|
||||
${logoUrl ? `<img src="${logoUrl}" alt="LexiChain" width="140" style="display:block;border-radius:10px;" />` : '<strong style="font-size:20px;">LexiChain</strong>'}
|
||||
</td>
|
||||
<td align="right" style="vertical-align:middle;font-size:12px;letter-spacing:1.4px;text-transform:uppercase;opacity:0.85;">
|
||||
Contract Intelligence
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h1 style="margin:20px 0 6px;font-size:26px;">Your contract insight report is ready</h1>
|
||||
<p style="margin:0;font-size:14px;opacity:0.9;">Clear, verified, and ready for action.</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blueprint</h3>
|
||||
<ul>
|
||||
<li><strong>Contract title:</strong> ${input.contractTitle}</li>
|
||||
<li><strong>Original file:</strong> ${input.contractFileName}</li>
|
||||
<li><strong>Type:</strong> ${input.blueprint.type}</li>
|
||||
<li><strong>Provider:</strong> ${input.blueprint.provider ?? "N/A"}</li>
|
||||
<li><strong>Policy number:</strong> ${input.blueprint.policyNumber ?? "N/A"}</li>
|
||||
<li><strong>Start date:</strong> ${formatDateValue(input.blueprint.startDate)}</li>
|
||||
<li><strong>End date:</strong> ${formatDateValue(input.blueprint.endDate)}</li>
|
||||
<li><strong>Premium:</strong> ${premiumLabel}</li>
|
||||
</ul>
|
||||
<tr>
|
||||
<td style="padding:24px 28px;">
|
||||
<p style="margin:0 0 16px;font-size:14px;">Hello ${recipientName},</p>
|
||||
<p style="margin:0 0 20px;font-size:14px;color:#334155;">
|
||||
Your LexiChain analysis is complete. Below is the executive blueprint and proof trace.
|
||||
</p>
|
||||
|
||||
<h3 style="margin-top: 24px; margin-bottom: 8px;">Summary</h3>
|
||||
<p>${input.blueprint.summary.replace(/\n/g, "<br />")}</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:16px;">
|
||||
<tr>
|
||||
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;">Blueprint Summary</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#475569;">
|
||||
<div><strong>Contract:</strong> ${input.contractTitle}</div>
|
||||
<div><strong>File:</strong> ${input.contractFileName}</div>
|
||||
<div><strong>Type:</strong> ${input.blueprint.type}</div>
|
||||
<div><strong>Provider:</strong> ${input.blueprint.provider ?? "N/A"}</div>
|
||||
<div><strong>Policy #:</strong> ${input.blueprint.policyNumber ?? "N/A"}</div>
|
||||
<div><strong>Start:</strong> ${formatDateValue(input.blueprint.startDate)}</div>
|
||||
<div><strong>End:</strong> ${formatDateValue(input.blueprint.endDate)}</div>
|
||||
<div><strong>Premium:</strong> ${premiumLabel}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blockchain Proof</h3>
|
||||
<ul>
|
||||
<li><strong>Status:</strong> ${blockchainStatus}</li>
|
||||
<li><strong>Document hash:</strong> ${input.blockchain?.documentHash ?? "N/A"}</li>
|
||||
<li><strong>Transaction hash:</strong> ${input.blockchain?.txHash ?? "N/A"}</li>
|
||||
<li><strong>Block number:</strong> ${input.blockchain?.blockNumber ?? "N/A"}</li>
|
||||
<li><strong>Block time:</strong> ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}</li>
|
||||
<li><strong>Network:</strong> ${input.blockchain?.network ?? "N/A"}</li>
|
||||
<li><strong>Contract address:</strong> ${input.blockchain?.contractAddress ?? "N/A"}</li>
|
||||
<li><strong>Explorer URL:</strong> ${input.blockchain?.explorerUrl ? `<a href="${input.blockchain.explorerUrl}" target="_blank" rel="noopener noreferrer">Open transaction</a>` : "N/A"}</li>
|
||||
</ul>
|
||||
<div style="margin:18px 0 8px;font-size:13px;font-weight:bold;">Executive Summary</div>
|
||||
<p style="margin:0 0 18px;font-size:13px;color:#475569;line-height:1.6;">${input.blueprint.summary.replace(/\n/g, "<br />")}</p>
|
||||
|
||||
${contractUrl ? `<p><a href="${contractUrl}">Open this contract in your dashboard</a></p>` : ""}
|
||||
<p style="margin-top: 24px; font-size: 12px; color: #475569;">Keep this email for your records.</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#eef2ff;border:1px solid #c7d2fe;border-radius:14px;padding:16px;">
|
||||
<tr>
|
||||
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;color:#1e3a8a;">Blockchain Proof</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:12px;color:#1e3a8a;line-height:1.6;">
|
||||
<div><strong>Status:</strong> ${blockchainStatus}</div>
|
||||
<div><strong>Document hash:</strong> ${input.blockchain?.documentHash ?? "N/A"}</div>
|
||||
<div><strong>Transaction hash:</strong> ${input.blockchain?.txHash ?? "N/A"}</div>
|
||||
<div><strong>Block number:</strong> ${input.blockchain?.blockNumber ?? "N/A"}</div>
|
||||
<div><strong>Block time:</strong> ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}</div>
|
||||
<div><strong>Network:</strong> ${input.blockchain?.network ?? "N/A"}</div>
|
||||
<div><strong>Contract address:</strong> ${input.blockchain?.contractAddress ?? "N/A"}</div>
|
||||
<div><strong>Explorer:</strong> ${input.blockchain?.explorerUrl ? `<a href="${input.blockchain.explorerUrl}" target="_blank" rel="noopener noreferrer" style="color:#1d4ed8;text-decoration:none;">Open transaction</a>` : "N/A"}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${
|
||||
contractUrl
|
||||
? `
|
||||
<div style="margin-top:22px;">
|
||||
<a href="${contractUrl}" style="display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;font-weight:bold;font-size:13px;">Open in LexiChain</a>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<p style="margin:20px 0 0;font-size:12px;color:#94a3b8;">Precision you can audit. Trust you can prove.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -259,11 +332,14 @@ export class EmailService {
|
||||
from,
|
||||
to: input.to,
|
||||
subject: `Contract analyzed: ${input.contractTitle}`,
|
||||
text: textBody,
|
||||
html: htmlBody,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
const previewUrl = nodemailer.getTestMessageUrl(info);
|
||||
const previewUrl =
|
||||
(nodemailer.getTestMessageUrl(info) as string | false) || null;
|
||||
if (previewUrl) {
|
||||
console.log(`📨 Ethereal preview URL: ${previewUrl}`);
|
||||
}
|
||||
@@ -277,4 +353,160 @@ export class EmailService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async sendContractDeadlineReminderEmail(
|
||||
input: ContractDeadlineEmailInput,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
skipped?: boolean;
|
||||
previewUrl?: string | null;
|
||||
}> {
|
||||
try {
|
||||
const mailer = await getTransporter();
|
||||
if (!mailer) {
|
||||
return {
|
||||
success: false,
|
||||
skipped: true,
|
||||
error: "Email service not configured",
|
||||
};
|
||||
}
|
||||
|
||||
const from =
|
||||
process.env.MAIL_FROM?.trim() ||
|
||||
process.env.EMAIL_USER?.trim() ||
|
||||
(transportMode === "ethereal"
|
||||
? "LexiChain <no-reply@ethereal.email>"
|
||||
: "");
|
||||
if (!from) {
|
||||
warnMissingEmailConfigOnce();
|
||||
return { success: false, skipped: true, error: "MAIL_FROM is missing" };
|
||||
}
|
||||
|
||||
if (!input.to?.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
skipped: true,
|
||||
error: "Recipient email is missing",
|
||||
};
|
||||
}
|
||||
|
||||
const recipientName = input.userDisplayName || "there";
|
||||
const contractUrl = formatContractLink(input.contractId);
|
||||
const logoUrl = getLogoUrl();
|
||||
const endDate = input.contractEndDate.toLocaleDateString();
|
||||
|
||||
const urgencyLabel =
|
||||
input.daysUntilExpiration <= 7
|
||||
? "Urgent"
|
||||
: input.daysUntilExpiration <= 14
|
||||
? "High"
|
||||
: "Planned";
|
||||
|
||||
const textBody = [
|
||||
`Hello ${recipientName},`,
|
||||
"",
|
||||
`Your contract deadline is approaching in ${input.daysUntilExpiration} days.`,
|
||||
"",
|
||||
`Contract: ${input.contractTitle ?? "Untitled contract"}`,
|
||||
`Provider: ${input.contractProvider ?? "N/A"}`,
|
||||
`End date: ${endDate}`,
|
||||
`Urgency: ${urgencyLabel}`,
|
||||
"",
|
||||
contractUrl ? `Open in app: ${contractUrl}` : "",
|
||||
"",
|
||||
"Please review the contract and schedule renewal if needed.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const htmlBody = `
|
||||
<div style="margin:0;padding:0;background:#0f172a;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#0f172a;padding:24px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="background:#ffffff;border-radius:20px;overflow:hidden;font-family:Arial, sans-serif;color:#0f172a;">
|
||||
<tr>
|
||||
<td style="padding:22px 28px;background:linear-gradient(135deg,#0f172a 0%,#2563eb 100%);color:#ffffff;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="left" style="vertical-align:middle;">
|
||||
${logoUrl ? `<img src="${logoUrl}" alt="LexiChain" width="130" style="display:block;border-radius:10px;" />` : '<strong style="font-size:18px;">LexiChain</strong>'}
|
||||
</td>
|
||||
<td align="right" style="vertical-align:middle;font-size:12px;letter-spacing:1.4px;text-transform:uppercase;opacity:0.85;">
|
||||
Deadline Alert
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h1 style="margin:18px 0 6px;font-size:24px;">Contract renewal reminder</h1>
|
||||
<p style="margin:0;font-size:14px;opacity:0.9;">Stay ahead of critical dates.</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:24px 28px;">
|
||||
<p style="margin:0 0 14px;font-size:14px;">Hello ${recipientName},</p>
|
||||
<p style="margin:0 0 18px;font-size:14px;color:#334155;">
|
||||
Your contract deadline is approaching in <strong>${input.daysUntilExpiration} days</strong>.
|
||||
</p>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:16px;">
|
||||
<tr>
|
||||
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;">Deadline Summary</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#475569;line-height:1.6;">
|
||||
<div><strong>Contract:</strong> ${input.contractTitle ?? "Untitled contract"}</div>
|
||||
<div><strong>Provider:</strong> ${input.contractProvider ?? "N/A"}</div>
|
||||
<div><strong>End date:</strong> ${endDate}</div>
|
||||
<div><strong>Urgency:</strong> ${urgencyLabel}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${
|
||||
contractUrl
|
||||
? `
|
||||
<div style="margin-top:20px;">
|
||||
<a href="${contractUrl}" style="display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;font-weight:bold;font-size:13px;">Review in LexiChain</a>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<p style="margin:18px 0 0;font-size:12px;color:#94a3b8;">Plan renewals early to avoid coverage gaps.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const info = await mailer.sendMail({
|
||||
from,
|
||||
to: input.to,
|
||||
subject: `Contract deadline in ${input.daysUntilExpiration} days`,
|
||||
html: htmlBody,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
const previewUrl =
|
||||
(nodemailer.getTestMessageUrl(info) as string | false) || null;
|
||||
if (previewUrl) {
|
||||
console.log(`📨 Ethereal preview URL: ${previewUrl}`);
|
||||
}
|
||||
|
||||
return { success: true, previewUrl };
|
||||
} catch (error) {
|
||||
console.error("Failed to send deadline reminder email:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown email error",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { EmailService } from "@/lib/services/email.service";
|
||||
|
||||
let hasWarnedMissingNotificationTable = false;
|
||||
|
||||
@@ -547,9 +548,9 @@ export class NotificationService {
|
||||
* Checks for upcoming contract renewals/expirations and creates notifications
|
||||
*
|
||||
* Scans all contracts for a user and creates DEADLINE notifications for:
|
||||
* - 30 days before expiration (CRITICAL)
|
||||
* - 15 days before expiration (WARNING)
|
||||
* - 7 days before expiration (URGENT)
|
||||
* - 30 days before expiration
|
||||
* - 14 days before expiration
|
||||
* - 7 days before expiration
|
||||
*
|
||||
* @param userId - The user's ID
|
||||
* @returns Promise with count of created notifications
|
||||
@@ -557,7 +558,7 @@ export class NotificationService {
|
||||
* Steps:
|
||||
* 1. Query all COMPLETED contracts with endDate for the user
|
||||
* 2. Calculate days until expiration
|
||||
* 3. Create notification if contract expiring in 30, 15, or 7 days
|
||||
* 3. Create notification if contract expiring in 30, 14, or 7 days
|
||||
* 4. Check for existing notification to avoid duplicates
|
||||
* 5. Return summary of created notifications
|
||||
*
|
||||
@@ -573,6 +574,15 @@ export class NotificationService {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Query all contracts with endDate for this user
|
||||
const contracts = await prisma.contract.findMany({
|
||||
where: {
|
||||
@@ -610,12 +620,12 @@ export class NotificationService {
|
||||
if (daysUntilExpiration === 7) {
|
||||
shouldNotify = true;
|
||||
level = "URGENT";
|
||||
} else if (daysUntilExpiration === 15) {
|
||||
} else if (daysUntilExpiration === 14) {
|
||||
shouldNotify = true;
|
||||
level = "WARNING";
|
||||
} else if (daysUntilExpiration === 30) {
|
||||
shouldNotify = true;
|
||||
level = "CRITICAL";
|
||||
level = "NOTICE";
|
||||
}
|
||||
|
||||
if (shouldNotify) {
|
||||
@@ -634,18 +644,21 @@ export class NotificationService {
|
||||
// Only create if not already notified today
|
||||
if (!existingNotification) {
|
||||
const notificationTitle =
|
||||
level === "CRITICAL"
|
||||
? `🔴 Contract Expiring in 30 Days`
|
||||
level === "NOTICE"
|
||||
? "Contract renewal reminder (30 days)"
|
||||
: level === "WARNING"
|
||||
? `🟠 Contract Expiring in 15 Days`
|
||||
: `🟡 Contract Expiring in 7 Days`;
|
||||
? "Contract renewal window (14 days)"
|
||||
: "Contract deadline in 7 days";
|
||||
|
||||
const providerLabel = contract.provider || "your provider";
|
||||
const contractLabel = contract.title || "your contract";
|
||||
|
||||
const notificationMessage =
|
||||
level === "CRITICAL"
|
||||
? `${contract.title} from ${contract.provider} will expire on ${contractEnd.toLocaleDateString()}. Time to renew!`
|
||||
level === "NOTICE"
|
||||
? `${contractLabel} from ${providerLabel} will expire on ${contractEnd.toLocaleDateString()}. Plan your renewal.`
|
||||
: level === "WARNING"
|
||||
? `${contract.title} from ${contract.provider} expires in 15 days. Consider scheduling renewal.`
|
||||
: `${contract.title} from ${contract.provider} expires in 7 days. Renew now!`;
|
||||
? `${contractLabel} from ${providerLabel} expires in 14 days. Please review renewal steps.`
|
||||
: `${contractLabel} from ${providerLabel} expires in 7 days. Action is required.`;
|
||||
|
||||
const result = await this.create({
|
||||
userId,
|
||||
@@ -654,7 +667,7 @@ export class NotificationService {
|
||||
message: notificationMessage,
|
||||
contractId: contract.id,
|
||||
actionType: `RENEWAL_${level}`,
|
||||
icon: level === "CRITICAL" ? "AlertCircle" : "AlertTriangle",
|
||||
icon: level === "URGENT" ? "AlertCircle" : "AlertTriangle",
|
||||
expiresIn: 24 * 60 * 60 * 1000, // 24 hours
|
||||
actionData: {
|
||||
level,
|
||||
@@ -667,6 +680,29 @@ export class NotificationService {
|
||||
|
||||
if (result.success) {
|
||||
createdNotifications.push(contract.id);
|
||||
|
||||
if (user?.email) {
|
||||
try {
|
||||
await EmailService.sendContractDeadlineReminderEmail({
|
||||
to: user.email,
|
||||
userDisplayName:
|
||||
`${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() ||
|
||||
null,
|
||||
contractId: contract.id,
|
||||
contractTitle: contract.title,
|
||||
contractProvider: contract.provider,
|
||||
contractEndDate: contractEnd,
|
||||
daysUntilExpiration,
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.warn(
|
||||
"Deadline email failed:",
|
||||
emailError instanceof Error
|
||||
? emailError.message
|
||||
: emailError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["pdfkit"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
470
package-lock.json
generated
470
package-lock.json
generated
@@ -58,7 +58,9 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^8.0.7",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdfkit": "^0.16.0",
|
||||
"prisma": "^6.19.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "19.2.3",
|
||||
@@ -83,6 +85,9 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20 <23"
|
||||
}
|
||||
},
|
||||
"node_modules/@adraffy/ens-normalize": {
|
||||
@@ -4402,6 +4407,30 @@
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -4722,6 +4751,26 @@
|
||||
"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": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
@@ -4773,6 +4822,30 @@
|
||||
"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": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
@@ -4897,6 +4970,15 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -4989,6 +5071,26 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -5014,6 +5116,24 @@
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -5080,6 +5200,12 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"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": {
|
||||
"version": "2.5.1",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -5427,6 +5568,12 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@@ -6288,7 +6435,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
@@ -6450,6 +6596,23 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -6582,6 +6745,15 @@
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -7122,6 +7294,15 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
@@ -7394,6 +7575,13 @@
|
||||
"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": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
@@ -7556,6 +7744,25 @@
|
||||
"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": {
|
||||
"version": "1.2.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
@@ -8206,7 +8422,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -8267,6 +8482,19 @@
|
||||
"@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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
@@ -8330,6 +8558,23 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -8583,6 +8828,23 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -8910,6 +9172,21 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -9069,6 +9352,12 @@
|
||||
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -9347,6 +9636,26 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
@@ -9714,6 +10035,12 @@
|
||||
"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": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -9983,6 +10310,32 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"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": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
||||
@@ -10303,6 +10656,12 @@
|
||||
"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": {
|
||||
"version": "1.1.20",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||
@@ -10335,6 +10694,20 @@
|
||||
"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": {
|
||||
"version": "8.17.1",
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@@ -10363,6 +10742,93 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
"name": "bfsi-project",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20 <23"
|
||||
},
|
||||
"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",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
@@ -58,8 +63,10 @@
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^8.0.7",
|
||||
"pdfkit": "^0.16.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"prisma": "^6.19.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "19.2.3",
|
||||
|
||||
BIN
public/screens/contracts_dark.png
Normal file
BIN
public/screens/contracts_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 955 KiB |
BIN
public/screens/dashboard_dark.png
Normal file
BIN
public/screens/dashboard_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
BIN
public/screens/landing_page.png
Normal file
BIN
public/screens/landing_page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/screens/login_dark.png
Normal file
BIN
public/screens/login_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
public/screens/register_dark.png
Normal file
BIN
public/screens/register_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
247
scripts/dev-with-chain.mjs
Normal file
247
scripts/dev-with-chain.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user