Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.env

# Editor directories and files
.vscode/*
Expand Down
1,220 changes: 1,086 additions & 134 deletions App.tsx

Large diffs are not rendered by default.

79 changes: 68 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ https://github.com/user-attachments/assets/1b2ab8e4-0129-4319-aa13-05d31d714266

## Features

### Extraction
- **AI-Powered Extraction**: Uses Google Gemini 2.5 Flash to visually identify signature pages and extract:
- **Party Name** (Entity bound by the contract)
- **Signatory Name** (Human signing the document)
Expand All @@ -18,12 +19,24 @@ https://github.com/user-attachments/assets/1b2ab8e4-0129-4319-aa13-05d31d714266
- **Agreement** (e.g., all pages for the SPA)
- **Counterparty** (e.g., all pages for "Acme Corp" across all docs)
- **Signatory** (e.g., all pages "Jane Smith" needs to sign)
- **Privacy-First**: Documents are processed in-memory. No file storage persistence.
- **Batch Processing**: Upload multiple transaction documents (PDF) at once.
- **Batch Processing**: Upload multiple transaction documents (PDF or DOCX) at once.
- **DOCX Conversion**: `.docx` uploads are converted to PDF through a server-side converter endpoint backed by Microsoft Graph (M365) to preserve layout fidelity.
- **Integrated Preview**: View original PDFs and extracted signature pages instantly.
- **Automatic Instructions**: Generates a clear signing table/instruction sheet for clients.
- **Automatic Instructions**: Generates a per-signatory signing table (with party and capacity) to send to your client.
- **Print-Ready Export**: Downloads a ZIP file containing perfectly sorted PDF packets for each party or agreement.

### Document Assembly
- **Executed Page Matching**: Upload signed/executed PDFs and let the AI identify which signature pages they correspond to.
- **Auto-Match**: Automatically matches executed pages to blank signature pages by document name, party, and signatory.
- **Assembly Progress Grid**: Visual checklist organized by signatory (columns) and document (rows) showing match status at a glance. Columns are drag-to-reorder.
- **Manual Override**: Click any cell to manually assign or reassign an executed page.
- **Assemble & Download**: Produces final assembled PDFs with blank signature pages swapped for their executed counterparts.

### Configuration
- **Save/Load Config**: Save your entire session (extracted pages, edits, assembly matches) to a `.json` file.
- **Bundled PDFs**: Saved configs embed the original PDF files so you can restore a full session without re-uploading anything.
- **Privacy-First**: PDF extraction and matching happen in-browser. If DOCX upload is enabled, DOCX files are sent only to your configured conversion endpoint.

## Tech Stack

- **Frontend**: React 19, Tailwind CSS, Lucide Icons
Expand All @@ -35,7 +48,7 @@ https://github.com/user-attachments/assets/1b2ab8e4-0129-4319-aa13-05d31d714266

1. **Clone the repository**:
```bash
git clone https://github.com/yourusername/signature-packet-ide.git
git clone https://github.com/jamietso/signature-packet-ide.git
cd signature-packet-ide
```

Expand All @@ -45,26 +58,70 @@ https://github.com/user-attachments/assets/1b2ab8e4-0129-4319-aa13-05d31d714266
```

3. **Environment Configuration**:
Create a `.env` file in the root directory and add your Google Gemini API Key:
Create a `.env` file in the root directory and add your Google Gemini API Key.
For DOCX conversion via M365, add Microsoft Graph app credentials:
```env
API_KEY=your_google_gemini_api_key_here
GEMINI_API_KEY=your_google_gemini_api_key_here
M365_TENANT_ID=your_microsoft_tenant_id
M365_CLIENT_ID=your_app_registration_client_id
M365_CLIENT_SECRET=your_app_registration_client_secret
M365_USER_ID=user-object-id-or-upn-for-conversion-drive
# Optional temporary folder in that user's OneDrive
M365_UPLOAD_FOLDER=SignaturePacketIDE-Temp
# Optional (defaults to /api/docx-to-pdf; works with Vite proxy)
VITE_DOCX_CONVERTER_URL=/api/docx-to-pdf
# Optional backend port (default 8787)
DOCX_CONVERTER_PORT=8787
```

4. **Run the application**:
4. **Run the full stack (frontend + DOCX converter backend)**:
```bash
npm start
npm run dev:full
```

## Usage Guide

1. **Upload**: Drag and drop your transaction documents (PDFs) into the sidebar.
2. **Review**: The AI will extract signature pages. Review the "Party", "Signatory", and "Capacity" fields in the card view.
### DOCX Conversion Endpoint Contract
- Method: `POST`
- URL: `VITE_DOCX_CONVERTER_URL` (defaults to `/api/docx-to-pdf`)
- Request: `multipart/form-data` with a `file` field containing `.docx`
- Response: `200` with `Content-Type: application/pdf` and raw PDF bytes
- Auth: this app sends `credentials: include`, so cookie/session-based auth is supported

### Local backend
- Backend entrypoint: `backend/server.ts`
- Health check: `GET /api/health`
- Converter route: `POST /api/docx-to-pdf`
- Uses Microsoft Graph conversion (`.../content?format=pdf`) for high-fidelity Office-to-PDF rendering.

### M365 permissions
- Register an app in Azure/Microsoft Entra and create a client secret.
- Add Microsoft Graph **Application** permissions:
- `Files.ReadWrite.All` (for upload + conversion + cleanup)
- Grant admin consent for your tenant.
- Set `M365_USER_ID` to a user/service account whose OneDrive will hold temporary uploads.

### Extract Mode
1. **Upload**: Drag and drop your transaction documents (PDFs or DOCX files) into the sidebar, then click **Extract**.
2. **Review**: The AI will identify signature pages. Review the "Party", "Signatory", and "Capacity" fields for each page.
3. **Adjust**:
- Use the **Grouping Toggles** (Agreement / Party / Signatory) to change how pages are sorted.
- Edit the **Copies** counter if a party needs to sign multiple originals.
4. **Instructions**: Click "Instructions" to view and copy a signing table to send to your client.
4. **Instructions**: Click "Instructions" to view and copy a per-signatory signing table to send to your client.
5. **Download**: Click "Download ZIP" to get the organized PDF packets.

### Assembly Mode
1. Switch to the **Assembly** tab in the toolbar.
2. **Upload Signed Pages**: Drop executed/scanned PDFs or DOCX files into the "Executed Pages" section of the sidebar.
3. **Auto-Match**: Click **Auto-Match** to let the AI match executed pages to their corresponding blank signature pages.
4. **Review**: The Assembly Progress grid shows each document (rows) × signatory (columns). Green = matched, amber = pending.
5. **Manual Override**: Click any cell to manually assign or reassign a page.
6. **Assemble & Download**: Click **Assemble & Download** to generate final PDFs with executed pages inserted.

### Save & Restore
- Click **Save Config** at any time to export your session (including all PDFs) to a `.json` file.
- Click **Load Config** to restore a previous session instantly — no re-uploading required.

## Contributing

Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
Expand Down
150 changes: 150 additions & 0 deletions backend/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import 'dotenv/config';
import express from 'express';
import multer from 'multer';
import { randomUUID } from 'node:crypto';

const app = express();
const port = Number(process.env.DOCX_CONVERTER_PORT || 8787);
const graphBaseUrl = process.env.M365_GRAPH_BASE_URL || 'https://graph.microsoft.com/v1.0';

const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 30 * 1024 * 1024 },
});

const requiredEnv = ['M365_TENANT_ID', 'M365_CLIENT_ID', 'M365_CLIENT_SECRET', 'M365_USER_ID'] as const;

const requireConfig = () => {
const missing = requiredEnv.filter((key) => !process.env[key]?.trim());
if (missing.length > 0) {
throw new Error(`Missing M365 converter config: ${missing.join(', ')}`);
}
return {
tenantId: process.env.M365_TENANT_ID!,
clientId: process.env.M365_CLIENT_ID!,
clientSecret: process.env.M365_CLIENT_SECRET!,
userId: process.env.M365_USER_ID!,
folder: process.env.M365_UPLOAD_FOLDER || 'SignaturePacketIDE-Temp',
};
};

const getGraphToken = async (): Promise<string> => {
const { tenantId, clientId, clientSecret } = requireConfig();
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope: 'https://graph.microsoft.com/.default',
});

const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});

if (!response.ok) {
const detail = await response.text();
throw new Error(`Token request failed (${response.status}): ${detail}`);
}

const json = await response.json() as { access_token?: string };
if (!json.access_token) {
throw new Error('Token response missing access_token');
}

return json.access_token;
};

const buildUserDrivePathUrl = (userId: string, folder: string, fileName: string): string => {
const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
const fullPath = `${folder}/${randomUUID()}-${safeName}`;
return `${graphBaseUrl}/users/${encodeURIComponent(userId)}/drive/root:/${fullPath}:/content`;
};

app.get('/api/health', (_req, res) => {
res.status(200).json({ ok: true });
});

app.post('/api/docx-to-pdf', upload.single('file'), async (req, res) => {
try {
const uploaded = req.file;
if (!uploaded) {
res.status(400).json({ error: 'No file uploaded. Use multipart/form-data with field "file".' });
return;
}

const looksLikeDocx =
uploaded.mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
uploaded.originalname.toLowerCase().endsWith('.docx');

if (!looksLikeDocx) {
res.status(400).json({ error: 'Only .docx files are supported for this endpoint.' });
return;
}

const token = await getGraphToken();
const { userId, folder } = requireConfig();
const uploadUrl = buildUserDrivePathUrl(userId, folder, uploaded.originalname);

// 1) Upload DOCX to service account OneDrive
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': uploaded.mimetype || 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
},
body: uploaded.buffer,
});

if (!uploadResponse.ok) {
const detail = await uploadResponse.text();
throw new Error(`Graph upload failed (${uploadResponse.status}): ${detail}`);
}

const uploadedItem = await uploadResponse.json() as { id?: string };
if (!uploadedItem.id) {
throw new Error('Graph upload response missing item id');
}

// 2) Request converted content as PDF
const convertUrl =
`${graphBaseUrl}/users/${encodeURIComponent(userId)}/drive/items/${encodeURIComponent(uploadedItem.id)}/content?format=pdf`;
const pdfResponse = await fetch(convertUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});

if (!pdfResponse.ok) {
const detail = await pdfResponse.text();
throw new Error(`Graph conversion failed (${pdfResponse.status}): ${detail}`);
}

const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());

// 3) Best-effort cleanup of temp file
const deleteUrl = `${graphBaseUrl}/users/${encodeURIComponent(userId)}/drive/items/${encodeURIComponent(uploadedItem.id)}`;
void fetch(deleteUrl, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
}).catch((cleanupErr) => {
console.warn('Cleanup warning:', cleanupErr);
});

const safeName = uploaded.originalname.replace(/\.docx$/i, '.pdf');
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${safeName}"`);
res.status(200).send(pdfBuffer);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown conversion error';
console.error('DOCX conversion failed:', message);
res.status(500).json({ error: 'DOCX conversion failed', detail: message });
}
});

app.listen(port, () => {
console.log(`DOCX converter API listening on http://localhost:${port}`);
});
13 changes: 13 additions & 0 deletions backend/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node"]
},
"include": ["./**/*.ts"]
}
Loading