mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Compare commits
6 Commits
exp/autopl
...
v2.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
| 50db4e39be | |||
| 2802813c76 | |||
| 29d40f1cca | |||
| d67f32eae2 | |||
| a33233443b | |||
| 68a3608aee |
@ -135,14 +135,6 @@ NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||
|
||||
# [[AI]]
|
||||
# AI Gateway
|
||||
AI_GATEWAY_API_KEY=""
|
||||
# OPTIONAL: API key for Google Generative AI (Gemini). Get your key from https://ai.google.dev
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=""
|
||||
# OPTIONAL: Enable AI field detection debug mode to save preview images with bounding boxes
|
||||
NEXT_PUBLIC_AI_DEBUG_PREVIEW=
|
||||
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -60,6 +60,3 @@ CLAUDE.md
|
||||
|
||||
# agents
|
||||
.specs
|
||||
|
||||
# ai debug previews
|
||||
packages/assets/ai-previews/
|
||||
|
||||
@ -11,10 +11,6 @@ import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fi
|
||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import {
|
||||
registerPageCanvas,
|
||||
unregisterPageCanvas,
|
||||
} from '@documenso/lib/client-only/utils/page-canvas-registry';
|
||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
MIN_FIELD_HEIGHT_PX,
|
||||
@ -60,15 +56,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
[editorFields.localFields, pageContext.pageNumber],
|
||||
);
|
||||
|
||||
/**
|
||||
* Cleanup: Unregister canvas when component unmounts
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unregisterPageCanvas(pageContext.pageNumber);
|
||||
};
|
||||
}, [pageContext.pageNumber]);
|
||||
|
||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||
const { current: container } = canvasElement;
|
||||
|
||||
@ -235,15 +222,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
currentStage.on('transformend', () => setIsFieldChanging(false));
|
||||
|
||||
currentPageLayer.batchDraw();
|
||||
|
||||
// Register this page's canvas references now that everything is initialized
|
||||
if (canvasElement.current && currentStage) {
|
||||
registerPageCanvas({
|
||||
pageNumber: pageContext.pageNumber,
|
||||
pdfCanvas: canvasElement.current,
|
||||
konvaStage: currentStage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
import { lazy, useEffect, useMemo } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@ -11,8 +11,6 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { getPageCanvasRefs } from '@documenso/lib/client-only/utils/page-canvas-registry';
|
||||
import type { TDetectedFormField } from '@documenso/lib/types/ai';
|
||||
import type {
|
||||
TCheckboxFieldMeta,
|
||||
TDateFieldMeta,
|
||||
@ -26,7 +24,6 @@ import type {
|
||||
TSignatureFieldMeta,
|
||||
TTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
@ -34,7 +31,6 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||
@ -54,136 +50,6 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
|
||||
async () => import('./envelope-editor-fields-page-renderer'),
|
||||
);
|
||||
|
||||
/**
|
||||
* Enforces minimum field dimensions and centers the field when expanding to meet minimums.
|
||||
*
|
||||
* AI often detects form lines as very thin fields (0.2-0.5% height). This function ensures
|
||||
* fields meet minimum usability requirements by expanding them to at least 30px height and
|
||||
* 36px width, while keeping them centered on their original position.
|
||||
*
|
||||
* @param params - Field dimensions and page size
|
||||
* @param params.positionX - Field X position as percentage (0-100)
|
||||
* @param params.positionY - Field Y position as percentage (0-100)
|
||||
* @param params.width - Field width as percentage (0-100)
|
||||
* @param params.height - Field height as percentage (0-100)
|
||||
* @param params.pageWidth - Page width in pixels
|
||||
* @param params.pageHeight - Page height in pixels
|
||||
* @returns Adjusted field dimensions with minimums enforced and centered
|
||||
*
|
||||
* @example
|
||||
* // AI detected a thin line: 0.3% height
|
||||
* const adjusted = enforceMinimumFieldDimensions({
|
||||
* positionX: 20, positionY: 50, width: 30, height: 0.3,
|
||||
* pageWidth: 800, pageHeight: 1100
|
||||
* });
|
||||
* // Result: height expanded to ~2.7% (30px), centered on original position
|
||||
*/
|
||||
const enforceMinimumFieldDimensions = (params: {
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
}): {
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} => {
|
||||
const MIN_HEIGHT_PX = 30;
|
||||
const MIN_WIDTH_PX = 36;
|
||||
|
||||
// Convert percentage to pixels to check against minimums
|
||||
const widthPx = (params.width / 100) * params.pageWidth;
|
||||
const heightPx = (params.height / 100) * params.pageHeight;
|
||||
|
||||
let adjustedWidth = params.width;
|
||||
let adjustedHeight = params.height;
|
||||
let adjustedPositionX = params.positionX;
|
||||
let adjustedPositionY = params.positionY;
|
||||
|
||||
if (widthPx < MIN_WIDTH_PX) {
|
||||
const centerXPx = (params.positionX / 100) * params.pageWidth + widthPx / 2;
|
||||
adjustedWidth = (MIN_WIDTH_PX / params.pageWidth) * 100;
|
||||
adjustedPositionX = ((centerXPx - MIN_WIDTH_PX / 2) / params.pageWidth) * 100;
|
||||
|
||||
if (adjustedPositionX < 0) {
|
||||
adjustedPositionX = 0;
|
||||
} else if (adjustedPositionX + adjustedWidth > 100) {
|
||||
adjustedPositionX = 100 - adjustedWidth;
|
||||
}
|
||||
}
|
||||
|
||||
if (heightPx < MIN_HEIGHT_PX) {
|
||||
const centerYPx = (params.positionY / 100) * params.pageHeight + heightPx / 2;
|
||||
adjustedHeight = (MIN_HEIGHT_PX / params.pageHeight) * 100;
|
||||
|
||||
adjustedPositionY = ((centerYPx - MIN_HEIGHT_PX / 2) / params.pageHeight) * 100;
|
||||
|
||||
if (adjustedPositionY < 0) {
|
||||
adjustedPositionY = 0;
|
||||
} else if (adjustedPositionY + adjustedHeight > 100) {
|
||||
adjustedPositionY = 100 - adjustedHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
positionX: adjustedPositionX,
|
||||
positionY: adjustedPositionY,
|
||||
width: adjustedWidth,
|
||||
height: adjustedHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const processAllPagesWithAI = async (params: {
|
||||
documentDataId: string;
|
||||
onProgress: (current: number, total: number) => void;
|
||||
}): Promise<{
|
||||
fieldsPerPage: Map<number, TDetectedFormField[]>;
|
||||
errors: Map<number, Error>;
|
||||
}> => {
|
||||
const { documentDataId, onProgress } = params;
|
||||
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
|
||||
const errors = new Map<number, Error>();
|
||||
|
||||
try {
|
||||
// Make single API call to process all pages server-side
|
||||
onProgress(0, 1);
|
||||
|
||||
const response = await fetch('/api/ai/detect-form-fields', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ documentId: documentDataId }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`AI detection failed: ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
|
||||
const detectedFields: TDetectedFormField[] = await response.json();
|
||||
|
||||
// Group fields by page number
|
||||
for (const field of detectedFields) {
|
||||
if (!fieldsPerPage.has(field.pageNumber)) {
|
||||
fieldsPerPage.set(field.pageNumber, []);
|
||||
}
|
||||
fieldsPerPage.get(field.pageNumber)!.push(field);
|
||||
}
|
||||
|
||||
onProgress(1, 1);
|
||||
} catch (error) {
|
||||
// If request fails, treat it as error for all pages
|
||||
errors.set(0, error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
|
||||
return { fieldsPerPage, errors };
|
||||
};
|
||||
|
||||
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
||||
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
||||
@ -204,13 +70,6 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
|
||||
const [processingProgress, setProcessingProgress] = useState<{
|
||||
current: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
@ -249,17 +108,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div className="relative flex w-full flex-col overflow-y-auto">
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
{/* Horizontal envelope item selector */}
|
||||
{isAutoAddingFields && (
|
||||
<>
|
||||
<div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-16" />
|
||||
<div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-16" />
|
||||
<div className="edge-glow edge-glow-bottom pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-16" />
|
||||
<div className="edge-glow edge-glow-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-16" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
|
||||
{/* Document View */}
|
||||
@ -349,141 +199,6 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
variant="outline"
|
||||
disabled={isAutoAddingFields}
|
||||
onClick={async () => {
|
||||
setIsAutoAddingFields(true);
|
||||
setProcessingProgress(null);
|
||||
|
||||
try {
|
||||
if (!editorFields.selectedRecipient || !currentEnvelopeItem) {
|
||||
toast({
|
||||
title: t`Warning`,
|
||||
description: t`Please select a recipient before adding fields.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentEnvelopeItem.documentDataId) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`Document data not found. Please try reloading the page.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { fieldsPerPage, errors } = await processAllPagesWithAI({
|
||||
documentDataId: currentEnvelopeItem.documentDataId,
|
||||
onProgress: (current, total) => {
|
||||
setProcessingProgress({ current, total });
|
||||
},
|
||||
});
|
||||
|
||||
let totalAdded = 0;
|
||||
for (const [pageNumber, detectedFields] of fieldsPerPage.entries()) {
|
||||
const pageCanvasRefs = getPageCanvasRefs(pageNumber);
|
||||
|
||||
for (const detected of detectedFields) {
|
||||
const [ymin, xmin, ymax, xmax] = detected.boundingBox;
|
||||
let positionX = (xmin / 1000) * 100;
|
||||
let positionY = (ymin / 1000) * 100;
|
||||
let width = ((xmax - xmin) / 1000) * 100;
|
||||
let height = ((ymax - ymin) / 1000) * 100;
|
||||
|
||||
if (pageCanvasRefs) {
|
||||
const adjusted = enforceMinimumFieldDimensions({
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
pageWidth: pageCanvasRefs.pdfCanvas.width,
|
||||
pageHeight: pageCanvasRefs.pdfCanvas.height,
|
||||
});
|
||||
|
||||
positionX = adjusted.positionX;
|
||||
positionY = adjusted.positionY;
|
||||
width = adjusted.width;
|
||||
height = adjusted.height;
|
||||
}
|
||||
|
||||
const fieldType = detected.label as FieldType;
|
||||
|
||||
try {
|
||||
editorFields.addField({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
page: pageNumber,
|
||||
type: fieldType,
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
recipientId: editorFields.selectedRecipient.id,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
|
||||
});
|
||||
totalAdded++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to add field on page ${pageNumber}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const successfulPages = fieldsPerPage.size;
|
||||
const failedPages = errors.size;
|
||||
|
||||
if (totalAdded > 0) {
|
||||
let description = t`Added ${totalAdded} fields`;
|
||||
if (fieldsPerPage.size > 1) {
|
||||
description = t`Added ${totalAdded} fields across ${successfulPages} pages`;
|
||||
}
|
||||
if (failedPages > 0) {
|
||||
description = t`Added ${totalAdded} fields across ${successfulPages} pages. ${failedPages} pages failed.`;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description,
|
||||
});
|
||||
} else if (failedPages > 0) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`Info`,
|
||||
description: t`No fields were detected in the document`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An unexpected error occurred while processing pages.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsAutoAddingFields(false);
|
||||
setProcessingProgress(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isAutoAddingFields ? (
|
||||
processingProgress ? (
|
||||
<Trans>
|
||||
Processing page {processingProgress.current} of {processingProgress.total}...
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Processing...</Trans>
|
||||
)
|
||||
) : (
|
||||
<Trans>Auto add fields</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{/* Field details section. */}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2, X } from 'lucide-react';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
|
||||
@ -14,8 +14,6 @@
|
||||
"with:env": "dotenv -e ../../.env -e ../../.env.local --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google": "^2.0.25",
|
||||
"@ai-sdk/react": "^2.0.82",
|
||||
"@cantoo/pdf-lib": "^2.5.2",
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
@ -41,7 +39,6 @@
|
||||
"@react-router/serve": "^7.6.0",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"ai": "^5.0.82",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"colord": "^2.9.3",
|
||||
"content-disposition": "^0.5.4",
|
||||
@ -73,7 +70,6 @@
|
||||
"remix-themes": "^2.0.4",
|
||||
"satori": "^0.12.1",
|
||||
"sharp": "0.32.6",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
@ -110,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.0.7"
|
||||
"version": "2.0.9"
|
||||
}
|
||||
|
||||
@ -1,420 +0,0 @@
|
||||
// sort-imports-ignore
|
||||
|
||||
// ---- PATCH pdfjs-dist's canvas require BEFORE importing it ----
|
||||
import { createRequire } from 'module';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Canvas, Image } from 'skia-canvas';
|
||||
|
||||
const require = createRequire(import.meta.url || fileURLToPath(new URL('.', import.meta.url)));
|
||||
const Module = require('module');
|
||||
|
||||
const originalRequire = Module.prototype.require;
|
||||
Module.prototype.require = function (path: string) {
|
||||
if (path === 'canvas') {
|
||||
return {
|
||||
createCanvas: (width: number, height: number) => new Canvas(width, height),
|
||||
Image, // needed by pdfjs-dist
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line prefer-rest-params, @typescript-eslint/consistent-type-assertions
|
||||
return originalRequire.apply(this, arguments as unknown as [string]);
|
||||
};
|
||||
|
||||
// Use dynamic require to bypass Vite SSR transformation
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pdfjsLib = require('pdfjs-dist/legacy/build/pdf.js');
|
||||
|
||||
import { generateObject } from 'ai';
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { Hono } from 'hono';
|
||||
import { join } from 'path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../router';
|
||||
import {
|
||||
type TDetectFormFieldsResponse,
|
||||
ZDetectFormFieldsRequestSchema,
|
||||
ZDetectedFormFieldSchema,
|
||||
} from './ai.types';
|
||||
|
||||
const renderPdfToImage = async (pdfBytes: Uint8Array) => {
|
||||
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
try {
|
||||
const scale = 4;
|
||||
|
||||
const pages = await Promise.all(
|
||||
Array.from({ length: pdf.numPages }, async (_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
|
||||
try {
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const virtualCanvas = new Canvas(viewport.width, viewport.height);
|
||||
const context = virtualCanvas.getContext('2d');
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
|
||||
return {
|
||||
image: await virtualCanvas.toBuffer('png'),
|
||||
pageNumber,
|
||||
width: Math.floor(viewport.width),
|
||||
height: Math.floor(viewport.height),
|
||||
};
|
||||
} finally {
|
||||
page.cleanup();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return pages;
|
||||
} finally {
|
||||
await pdf.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const resizeAndCompressImage = async (imageBuffer: Buffer): Promise<Buffer> => {
|
||||
const metadata = await sharp(imageBuffer).metadata();
|
||||
const originalWidth = metadata.width || 0;
|
||||
|
||||
if (originalWidth > 1000) {
|
||||
return await sharp(imageBuffer)
|
||||
.resize({ width: 1000, withoutEnlargement: true })
|
||||
.jpeg({ quality: 70 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
return await sharp(imageBuffer).jpeg({ quality: 70 }).toBuffer();
|
||||
};
|
||||
|
||||
const detectObjectsPrompt = `You are analyzing a form document image to detect fillable fields for the Documenso document signing platform.
|
||||
|
||||
IMPORTANT RULES:
|
||||
1. Only detect EMPTY/UNFILLED fields (ignore boxes that already contain text or data)
|
||||
2. Analyze nearby text labels to determine the field type
|
||||
3. Return bounding boxes for the fillable area only, NOT the label text
|
||||
4. Each boundingBox must be in the format [ymin, xmin, ymax, xmax] where all coordinates are NORMALIZED to a 0-1000 scale
|
||||
|
||||
CRITICAL: UNDERSTANDING FILLABLE AREAS
|
||||
The "fillable area" is ONLY the empty space where a user will write, type, sign, or check.
|
||||
- ✓ CORRECT: The blank underscore where someone writes their name: "Name: _________" → box ONLY the underscores
|
||||
- ✓ CORRECT: The empty white rectangle inside a box outline → box ONLY the empty space, not any printed text
|
||||
- ✓ CORRECT: The blank space to the right of a label: "Email: [ empty box ]" → box ONLY the empty box, exclude "Email:"
|
||||
- ✗ INCORRECT: Including the word "Signature:" that appears to the left of a signature line
|
||||
- ✗ INCORRECT: Including printed labels, instructions, or descriptive text near the field
|
||||
- ✗ INCORRECT: Extending the box to include text just because it's close to the fillable area
|
||||
|
||||
VISUALIZING THE DISTINCTION:
|
||||
- If there's text (printed words/labels) near an empty box or line, they are SEPARATE elements
|
||||
- The text is a LABEL telling the user what to fill
|
||||
- The empty space is the FILLABLE AREA where they actually write/sign
|
||||
- Your bounding box should capture ONLY the empty space, even if the label is immediately adjacent
|
||||
|
||||
FIELD TYPES TO DETECT:
|
||||
• SIGNATURE - Signature lines, boxes labeled 'Signature', 'Sign here', 'Authorized signature', 'X____'
|
||||
• INITIALS - Small boxes labeled 'Initials', 'Initial here', typically smaller than signature fields
|
||||
• NAME - Boxes labeled 'Name', 'Full name', 'Your name', 'Print name', 'Printed name'
|
||||
• EMAIL - Boxes labeled 'Email', 'Email address', 'E-mail', 'Email:'
|
||||
• DATE - Boxes labeled 'Date', 'Date signed', "Today's date", or showing date format placeholders like 'MM/DD/YYYY', '__/__/____'
|
||||
• CHECKBOX - Empty checkbox squares (☐) with or without labels, typically small square boxes
|
||||
• RADIO - Empty radio button circles (○) in groups, typically circular selection options
|
||||
• NUMBER - Boxes labeled with numeric context: 'Amount', 'Quantity', 'Phone', 'Phone number', 'ZIP', 'ZIP code', 'Age', 'Price', '#'
|
||||
• DROPDOWN - Boxes with dropdown indicators (▼, ↓) or labeled 'Select', 'Choose', 'Please select'
|
||||
• TEXT - Any other empty text input boxes, general input fields, unlabeled boxes, or when field type is uncertain
|
||||
|
||||
DETECTION GUIDELINES:
|
||||
- Read text located near the box (above, to the left, or inside the box boundary) to infer the field type
|
||||
- IMPORTANT: Use the nearby text to CLASSIFY the field type, but DO NOT include that text in the bounding box
|
||||
- If you're uncertain which type fits best, default to TEXT
|
||||
- For checkboxes and radio buttons: Detect each individual box/circle separately, not the label
|
||||
- Signature fields are often longer horizontal lines or larger boxes
|
||||
- Date fields often show format hints or date separators (slashes, dashes)
|
||||
- Look for visual patterns: underscores (____), horizontal lines, box outlines
|
||||
|
||||
BOUNDING BOX PLACEMENT (CRITICAL):
|
||||
- Your coordinates must capture ONLY the empty fillable space (the blank area where input goes)
|
||||
- EXCLUDE all printed text labels, even if they are:
|
||||
· Directly to the left of the field (e.g., "Name: _____")
|
||||
· Directly above the field (e.g., "Signature" printed above a line)
|
||||
· Very close to the field with minimal spacing
|
||||
· Inside the same outlined box as the fillable area
|
||||
- The label text helps you IDENTIFY the field type, but must be EXCLUDED from the bounding box
|
||||
- If you detect a label "Email:" followed by a blank box, draw the box around ONLY the blank box, not the word "Email:"
|
||||
|
||||
COORDINATE SYSTEM:
|
||||
- [ymin, xmin, ymax, xmax] normalized to 0-1000 scale
|
||||
- Top-left corner: ymin and xmin close to 0
|
||||
- Bottom-right corner: ymax and xmax close to 1000
|
||||
- Coordinates represent positions on a 1000x1000 grid overlaid on the image
|
||||
|
||||
FIELD SIZING STRATEGY FOR LINE-BASED FIELDS:
|
||||
When detecting thin horizontal lines for SIGNATURE, INITIALS, NAME, EMAIL, DATE, TEXT, or NUMBER fields:
|
||||
1. Analyze the visual context around the detected line:
|
||||
- Look at the empty space ABOVE the detected line
|
||||
- Observe the spacing to any text labels, headers, or other form elements above
|
||||
- Assess what would be a reasonable field height to make the field clearly visible when filled
|
||||
2. Expand UPWARD from the detected line to create a usable field:
|
||||
- Keep ymax (bottom) at the detected line position (the line becomes the bottom edge)
|
||||
- Extend ymin (top) upward into the available whitespace
|
||||
- Aim to use 60-80% of the clear whitespace above the line, while being reasonable
|
||||
- The expanded field should provide comfortable space for signing/writing (minimum 30 units tall)
|
||||
3. Apply minimum dimensions: height at least 30 units (3% of 1000-scale), width at least 36 units
|
||||
4. Ensure ymin >= 0 (do not go off-page). If ymin would be negative, clamp to 0
|
||||
5. Do NOT apply this expansion to CHECKBOX, RADIO, or DROPDOWN fields - use detected dimensions for those
|
||||
6. Example: If you detect a signature line at ymax=500 with clear whitespace extending up to y=400:
|
||||
- Available whitespace: 100 units
|
||||
- Use 60-80% of that: 60-80 units
|
||||
- Expanded field: [ymin=420, xmin=200, ymax=500, xmax=600] (creates 80-unit tall field)
|
||||
- This gives comfortable signing space while respecting the form layout`;
|
||||
|
||||
const runFormFieldDetection = async (
|
||||
imageBuffer: Buffer,
|
||||
pageNumber: number,
|
||||
): Promise<TDetectFormFieldsResponse> => {
|
||||
const compressedImageBuffer = await resizeAndCompressImage(imageBuffer);
|
||||
const base64Image = compressedImageBuffer.toString('base64');
|
||||
|
||||
const result = await generateObject({
|
||||
model: 'google/gemini-2.5-pro',
|
||||
output: 'array',
|
||||
schema: ZDetectedFormFieldSchema,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
image: `data:image/jpeg;base64,${base64Image}`,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: detectObjectsPrompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return result.object.map((field) => ({
|
||||
...field,
|
||||
pageNumber,
|
||||
}));
|
||||
};
|
||||
|
||||
export const aiRoute = new Hono<HonoEnv>().post('/detect-form-fields', async (c) => {
|
||||
try {
|
||||
const { user } = await getSession(c.req.raw);
|
||||
|
||||
const body = await c.req.json();
|
||||
const parsed = ZDetectFormFieldsRequestSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document ID is required',
|
||||
userMessage: 'Please provide a valid document ID.',
|
||||
});
|
||||
}
|
||||
|
||||
const { documentId } = parsed.data;
|
||||
|
||||
const documentData = await prisma.documentData.findUnique({
|
||||
where: { id: documentId },
|
||||
include: {
|
||||
envelopeItem: {
|
||||
include: {
|
||||
envelope: {
|
||||
select: {
|
||||
userId: true,
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!documentData || !documentData.envelopeItem) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Document data not found: ${documentId}`,
|
||||
userMessage: 'The requested document does not exist.',
|
||||
});
|
||||
}
|
||||
|
||||
const envelope = documentData.envelopeItem.envelope;
|
||||
|
||||
const isDirectOwner = envelope.userId === user.id;
|
||||
|
||||
let hasTeamAccess = false;
|
||||
if (envelope.teamId) {
|
||||
try {
|
||||
await getTeamById({ teamId: envelope.teamId, userId: user.id });
|
||||
hasTeamAccess = true;
|
||||
} catch (error) {
|
||||
hasTeamAccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDirectOwner && !hasTeamAccess) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: `User ${user.id} does not have access to document ${documentId}`,
|
||||
userMessage: 'You do not have permission to access this document.',
|
||||
});
|
||||
}
|
||||
|
||||
const pdfBytes = await getFileServerSide({
|
||||
type: documentData.type,
|
||||
data: documentData.initialData || documentData.data,
|
||||
});
|
||||
|
||||
const renderedPages = await renderPdfToImage(pdfBytes);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
renderedPages.map(async (page) => {
|
||||
return await runFormFieldDetection(page.image, page.pageNumber);
|
||||
}),
|
||||
);
|
||||
|
||||
const detectedFields: TDetectFormFieldsResponse = [];
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === 'fulfilled') {
|
||||
detectedFields.push(...result.value);
|
||||
} else {
|
||||
const pageNumber = renderedPages[index]?.pageNumber ?? index + 1;
|
||||
console.error(`Failed to detect fields on page ${pageNumber}:`, result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (env('NEXT_PUBLIC_AI_DEBUG_PREVIEW') === 'true') {
|
||||
const debugDir = join(process.cwd(), '..', '..', 'packages', 'assets', 'ai-previews');
|
||||
await mkdir(debugDir, { recursive: true });
|
||||
|
||||
const now = new Date();
|
||||
const timestamp = now
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\..+/, '')
|
||||
.replace('T', '_');
|
||||
|
||||
for (const page of renderedPages) {
|
||||
const padding = { left: 80, top: 20, right: 20, bottom: 40 };
|
||||
const canvas = new Canvas(
|
||||
page.width + padding.left + padding.right,
|
||||
page.height + padding.top + padding.bottom,
|
||||
);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const img = new Image();
|
||||
img.src = page.image;
|
||||
ctx.drawImage(img, padding.left, padding.top);
|
||||
|
||||
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let i = 0; i <= 1000; i += 100) {
|
||||
const x = padding.left + (i / 1000) * page.width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padding.top);
|
||||
ctx.lineTo(x, page.height + padding.top);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (let i = 0; i <= 1000; i += 100) {
|
||||
const y = padding.top + (i / 1000) * page.height;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(page.width + padding.left, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
|
||||
|
||||
const pageFields = detectedFields.filter((f) => f.pageNumber === page.pageNumber);
|
||||
pageFields.forEach((field, index) => {
|
||||
const [ymin, xmin, ymax, xmax] = field.boundingBox.map((coord) => coord / 1000);
|
||||
|
||||
const x = xmin * page.width + padding.left;
|
||||
const y = ymin * page.height + padding.top;
|
||||
const width = (xmax - xmin) * page.width;
|
||||
const height = (ymax - ymin) * page.height;
|
||||
|
||||
ctx.strokeStyle = colors[index % colors.length];
|
||||
ctx.lineWidth = 5;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
|
||||
ctx.fillStyle = colors[index % colors.length];
|
||||
ctx.font = '20px Arial';
|
||||
ctx.fillText(field.label, x, y - 5);
|
||||
});
|
||||
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.font = '26px Arial';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, padding.top);
|
||||
ctx.lineTo(padding.left, page.height + padding.top);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let i = 0; i <= 1000; i += 100) {
|
||||
const y = padding.top + (i / 1000) * page.height;
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillText(i.toString(), padding.left - 5, y);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left - 5, y);
|
||||
ctx.lineTo(padding.left, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, page.height + padding.top);
|
||||
ctx.lineTo(page.width + padding.left, page.height + padding.top);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
for (let i = 0; i <= 1000; i += 100) {
|
||||
const x = padding.left + (i / 1000) * page.width;
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillText(i.toString(), x, page.height + padding.top + 5);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, page.height + padding.top);
|
||||
ctx.lineTo(x, page.height + padding.top + 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const outputFilename = `detected_form_fields_${timestamp}_page_${page.pageNumber}.png`;
|
||||
const outputPath = join(debugDir, outputFilename);
|
||||
|
||||
const pngBuffer = await canvas.toBuffer('png');
|
||||
await writeFile(outputPath, new Uint8Array(pngBuffer));
|
||||
}
|
||||
}
|
||||
|
||||
return c.json<TDetectFormFieldsResponse>(detectedFields);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('Failed to detect form fields from PDF:', error);
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: `Failed to detect form fields from PDF: ${error instanceof Error ? error.message : String(error)}`,
|
||||
userMessage: 'An error occurred while detecting form fields. Please try again.',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -1,50 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TDetectedFormField } from '@documenso/lib/types/ai';
|
||||
|
||||
export const ZGenerateTextRequestSchema = z.object({
|
||||
prompt: z.string().min(1, 'Prompt is required').max(5000, 'Prompt is too long'),
|
||||
});
|
||||
|
||||
export const ZGenerateTextResponseSchema = z.object({
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export type TGenerateTextRequest = z.infer<typeof ZGenerateTextRequestSchema>;
|
||||
export type TGenerateTextResponse = z.infer<typeof ZGenerateTextResponseSchema>;
|
||||
|
||||
export const ZDetectedFormFieldSchema = z.object({
|
||||
boundingBox: z
|
||||
.array(z.number())
|
||||
.length(4)
|
||||
.describe('Bounding box [ymin, xmin, ymax, xmax] in normalized 0-1000 range'),
|
||||
label: z
|
||||
.enum([
|
||||
'SIGNATURE',
|
||||
'INITIALS',
|
||||
'NAME',
|
||||
'EMAIL',
|
||||
'DATE',
|
||||
'TEXT',
|
||||
'NUMBER',
|
||||
'RADIO',
|
||||
'CHECKBOX',
|
||||
'DROPDOWN',
|
||||
])
|
||||
.describe('Documenso field type inferred from nearby label text or visual characteristics'),
|
||||
pageNumber: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe('1-indexed page number where field was detected'),
|
||||
});
|
||||
|
||||
export const ZDetectFormFieldsRequestSchema = z.object({
|
||||
documentId: z.string().min(1, { message: 'Document ID is required' }),
|
||||
});
|
||||
|
||||
export const ZDetectFormFieldsResponseSchema = z.array(ZDetectedFormFieldSchema);
|
||||
|
||||
export type TDetectFormFieldsRequest = z.infer<typeof ZDetectFormFieldsRequestSchema>;
|
||||
export type TDetectFormFieldsResponse = z.infer<typeof ZDetectFormFieldsResponseSchema>;
|
||||
export type { TDetectedFormField };
|
||||
@ -21,7 +21,7 @@ export const downloadRoute = new Hono<HonoEnv>()
|
||||
* Requires API key authentication via Authorization header.
|
||||
*/
|
||||
.get(
|
||||
'/envelopeItem/:envelopeItemId/download',
|
||||
'/envelope/item/:envelopeItemId/download',
|
||||
sValidator('param', ZDownloadEnvelopeItemRequestParamsSchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
@ -14,7 +14,6 @@ import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||
|
||||
import { aiRoute } from './api/ai';
|
||||
import { downloadRoute } from './api/download/download';
|
||||
import { filesRoute } from './api/files/files';
|
||||
import { type AppContext, appContext } from './context';
|
||||
@ -85,9 +84,6 @@ app.route('/api/auth', auth);
|
||||
// Files route.
|
||||
app.route('/api/files', filesRoute);
|
||||
|
||||
// AI route.
|
||||
app.route('/api/ai', aiRoute);
|
||||
|
||||
// API servers.
|
||||
app.use(`/api/v1/*`, cors());
|
||||
app.route('/api/v1', tsRestHonoApp);
|
||||
|
||||
8011
package-lock.json
generated
8011
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.9",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
@ -83,7 +83,6 @@
|
||||
"@documenso/prisma": "^0.0.0",
|
||||
"@lingui/conf": "^5.2.0",
|
||||
"@lingui/core": "^5.2.0",
|
||||
"ai": "^5.0.82",
|
||||
"inngest-cli": "^0.29.1",
|
||||
"luxon": "^3.5.0",
|
||||
"mupdf": "^1.0.0",
|
||||
|
||||
@ -1,110 +0,0 @@
|
||||
import type Konva from 'konva';
|
||||
|
||||
/**
|
||||
* Represents canvas references for a specific PDF page.
|
||||
*/
|
||||
export interface PageCanvasRefs {
|
||||
/** The page number (1-indexed) */
|
||||
pageNumber: number;
|
||||
/** The canvas element containing the rendered PDF */
|
||||
pdfCanvas: HTMLCanvasElement;
|
||||
/** The Konva stage containing field overlays */
|
||||
konvaStage: Konva.Stage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level registry to store canvas references by page number.
|
||||
* This allows any component to access page canvases without prop drilling.
|
||||
*/
|
||||
const pageCanvasRegistry = new Map<number, PageCanvasRefs>();
|
||||
|
||||
/**
|
||||
* Register a page's canvas references.
|
||||
* Call this when a page renderer mounts and has valid canvas refs.
|
||||
*
|
||||
* @param refs - The canvas references to register
|
||||
*/
|
||||
export const registerPageCanvas = (refs: PageCanvasRefs): void => {
|
||||
pageCanvasRegistry.set(refs.pageNumber, refs);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unregister a page's canvas references.
|
||||
* Call this when a page renderer unmounts to prevent memory leaks.
|
||||
*
|
||||
* @param pageNumber - The page number to unregister
|
||||
*/
|
||||
export const unregisterPageCanvas = (pageNumber: number): void => {
|
||||
pageCanvasRegistry.delete(pageNumber);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get canvas references for a specific page.
|
||||
*
|
||||
* @param pageNumber - The page number to retrieve
|
||||
* @returns The canvas references, or undefined if not registered
|
||||
*/
|
||||
export const getPageCanvasRefs = (pageNumber: number): PageCanvasRefs | undefined => {
|
||||
return pageCanvasRegistry.get(pageNumber);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all registered page numbers.
|
||||
*
|
||||
* @returns Array of page numbers currently registered
|
||||
*/
|
||||
export const getRegisteredPageNumbers = (): number[] => {
|
||||
return Array.from(pageCanvasRegistry.keys()).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
/**
|
||||
* Composite a PDF page with its field overlays into a single PNG Blob.
|
||||
* This creates a temporary canvas, draws the PDF canvas first (background),
|
||||
* then draws the Konva canvas on top (field overlays).
|
||||
*
|
||||
* @param pageNumber - The page number to composite (1-indexed)
|
||||
* @returns Promise that resolves to a PNG Blob, or null if page not found or compositing fails
|
||||
*/
|
||||
export const compositePageToBlob = async (pageNumber: number): Promise<Blob | null> => {
|
||||
const refs = getPageCanvasRefs(pageNumber);
|
||||
|
||||
if (!refs) {
|
||||
console.warn(`Page ${pageNumber} is not registered for canvas capture`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create temporary canvas with same dimensions as PDF canvas
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = refs.pdfCanvas.width;
|
||||
tempCanvas.height = refs.pdfCanvas.height;
|
||||
|
||||
const ctx = tempCanvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
console.error('Failed to get 2D context for temporary canvas');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Draw PDF canvas first (background layer)
|
||||
ctx.drawImage(refs.pdfCanvas, 0, 0);
|
||||
|
||||
// Get Konva canvas and draw on top (field overlays)
|
||||
// Note: Konva's toCanvas() returns a new canvas with all layers rendered
|
||||
const konvaCanvas = refs.konvaStage.toCanvas();
|
||||
ctx.drawImage(konvaCanvas, 0, 0);
|
||||
|
||||
// Convert to PNG Blob
|
||||
return new Promise((resolve, reject) => {
|
||||
tempCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Failed to convert canvas to blob'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error compositing page ${pageNumber}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@ -43,7 +43,6 @@
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"pg": "^8.11.3",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,15 +0,0 @@
|
||||
export type TDetectedFormField = {
|
||||
boundingBox: number[];
|
||||
label:
|
||||
| 'SIGNATURE'
|
||||
| 'INITIALS'
|
||||
| 'NAME'
|
||||
| 'EMAIL'
|
||||
| 'DATE'
|
||||
| 'TEXT'
|
||||
| 'NUMBER'
|
||||
| 'RADIO'
|
||||
| 'CHECKBOX'
|
||||
| 'DROPDOWN';
|
||||
pageNumber: number;
|
||||
};
|
||||
@ -63,10 +63,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
id: true,
|
||||
title: true,
|
||||
order: true,
|
||||
documentDataId: true,
|
||||
})
|
||||
.partial({ documentDataId: true })
|
||||
.array(),
|
||||
}).array(),
|
||||
directLink: TemplateDirectLinkSchema.pick({
|
||||
directTemplateRecipientId: true,
|
||||
enabled: true,
|
||||
|
||||
@ -303,7 +303,6 @@ export const FIELD_NUMBER_META_DEFAULT_VALUES: TNumberFieldMeta = {
|
||||
textAlign: 'left',
|
||||
label: '',
|
||||
placeholder: '',
|
||||
value: '',
|
||||
required: false,
|
||||
readOnly: false,
|
||||
};
|
||||
@ -373,3 +372,52 @@ export const FIELD_META_DEFAULT_VALUES: Record<FieldType, TFieldMetaSchema> = {
|
||||
[FieldType.CHECKBOX]: FIELD_CHECKBOX_META_DEFAULT_VALUES,
|
||||
[FieldType.DROPDOWN]: FIELD_DROPDOWN_META_DEFAULT_VALUES,
|
||||
} as const;
|
||||
|
||||
export const ZEnvelopeFieldAndMetaSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(FieldType.SIGNATURE),
|
||||
fieldMeta: ZSignatureFieldMeta.optional().default(FIELD_SIGNATURE_META_DEFAULT_VALUES),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.FREE_SIGNATURE),
|
||||
fieldMeta: z.undefined(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.INITIALS),
|
||||
fieldMeta: ZInitialsFieldMeta.optional().default(FIELD_INITIALS_META_DEFAULT_VALUES),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.NAME),
|
||||
fieldMeta: ZNameFieldMeta.optional().default(FIELD_NAME_META_DEFAULT_VALUES),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.EMAIL),
|
||||
fieldMeta: ZEmailFieldMeta.optional().default(FIELD_EMAIL_META_DEFAULT_VALUES),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.DATE),
|
||||
fieldMeta: ZDateFieldMeta.optional().default(FIELD_DATE_META_DEFAULT_VALUES),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.TEXT),
|
||||
fieldMeta: ZTextFieldMeta.optional().default(FIELD_TEXT_META_DEFAULT_VALUES),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.NUMBER),
|
||||
fieldMeta: ZNumberFieldMeta.optional().default(FIELD_NUMBER_META_DEFAULT_VALUES),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.RADIO),
|
||||
fieldMeta: ZRadioFieldMeta.optional().default(FIELD_RADIO_META_DEFAULT_VALUES),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.CHECKBOX),
|
||||
fieldMeta: ZCheckboxFieldMeta.optional().default(FIELD_CHECKBOX_META_DEFAULT_VALUES),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.DROPDOWN),
|
||||
fieldMeta: ZDropdownFieldMeta.optional().default(FIELD_DROPDOWN_META_DEFAULT_VALUES),
|
||||
}),
|
||||
]);
|
||||
|
||||
type TEnvelopeFieldAndMeta = z.infer<typeof ZEnvelopeFieldAndMetaSchema>;
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
/**
|
||||
* Shared constants for field dimension enforcement.
|
||||
*
|
||||
* These constants ensure consistency between:
|
||||
* 1. AI prompt (server/api/ai.ts) - instructs Gemini on minimum field dimensions
|
||||
* 2. Client enforcement (envelope-editor-fields-page.tsx) - fallback validation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimum field height in pixels.
|
||||
* Fields smaller than this will be expanded to meet minimum usability requirements.
|
||||
*/
|
||||
export const MIN_FIELD_HEIGHT_PX = 30;
|
||||
|
||||
/**
|
||||
* Minimum field width in pixels.
|
||||
* Fields smaller than this will be expanded to meet minimum usability requirements.
|
||||
*/
|
||||
export const MIN_FIELD_WIDTH_PX = 36;
|
||||
@ -41,10 +41,14 @@ export const createAttachmentRoute = authenticatedProcedure
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
});
|
||||
|
||||
await createAttachment({
|
||||
const attachment = await createAttachment({
|
||||
envelopeId: envelope.id,
|
||||
teamId,
|
||||
userId,
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
id: attachment.id,
|
||||
};
|
||||
});
|
||||
|
||||
@ -8,7 +8,9 @@ export const ZCreateAttachmentRequestSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZCreateAttachmentResponseSchema = z.void();
|
||||
export const ZCreateAttachmentResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type TCreateAttachmentRequest = z.infer<typeof ZCreateAttachmentRequestSchema>;
|
||||
export type TCreateAttachmentResponse = z.infer<typeof ZCreateAttachmentResponseSchema>;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { deleteAttachment } from '@documenso/lib/server-only/envelope-attachment/delete-attachment';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../../schema';
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZDeleteAttachmentRequestSchema,
|
||||
@ -33,4 +34,6 @@ export const deleteAttachmentRoute = authenticatedProcedure
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../../schema';
|
||||
|
||||
export const ZDeleteAttachmentRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeleteAttachmentResponseSchema = z.void();
|
||||
export const ZDeleteAttachmentResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TDeleteAttachmentRequest = z.infer<typeof ZDeleteAttachmentRequestSchema>;
|
||||
export type TDeleteAttachmentResponse = z.infer<typeof ZDeleteAttachmentResponseSchema>;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { updateAttachment } from '@documenso/lib/server-only/envelope-attachment/update-attachment';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../../schema';
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZUpdateAttachmentRequestSchema,
|
||||
@ -34,4 +35,6 @@ export const updateAttachmentRoute = authenticatedProcedure
|
||||
teamId,
|
||||
data,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../../schema';
|
||||
|
||||
export const ZUpdateAttachmentRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
data: z.object({
|
||||
@ -8,7 +10,7 @@ export const ZUpdateAttachmentRequestSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateAttachmentResponseSchema = z.void();
|
||||
export const ZUpdateAttachmentResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TUpdateAttachmentRequest = z.infer<typeof ZUpdateAttachmentRequestSchema>;
|
||||
export type TUpdateAttachmentResponse = z.infer<typeof ZUpdateAttachmentResponseSchema>;
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../schema';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZDeleteDocumentRequestSchema,
|
||||
ZDeleteDocumentResponseSchema,
|
||||
deleteDocumentMeta,
|
||||
} from './delete-document.types';
|
||||
import { ZGenericSuccessResponse } from './schema';
|
||||
|
||||
export const deleteDocumentRoute = authenticatedProcedure
|
||||
.meta(deleteDocumentMeta)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../schema';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
import { ZSuccessResponseSchema } from './schema';
|
||||
|
||||
export const deleteDocumentMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
|
||||
@ -19,5 +19,6 @@ export const downloadDocumentRoute = authenticatedProcedure
|
||||
},
|
||||
});
|
||||
|
||||
// This endpoint is purely for V2 API, which is implemented in the Hono remix server.
|
||||
throw new Error('NOT_IMPLEMENTED');
|
||||
});
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../schema';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZRedistributeDocumentRequestSchema,
|
||||
ZRedistributeDocumentResponseSchema,
|
||||
redistributeDocumentMeta,
|
||||
} from './redistribute-document.types';
|
||||
import { ZGenericSuccessResponse } from './schema';
|
||||
|
||||
export const redistributeDocumentRoute = authenticatedProcedure
|
||||
.meta(redistributeDocumentMeta)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../schema';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
import { ZSuccessResponseSchema } from './schema';
|
||||
|
||||
export const redistributeDocumentMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
|
||||
@ -1,19 +1,6 @@
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Required for empty responses since we currently can't 201 requests for our openapi setup.
|
||||
*
|
||||
* Without this it will throw an error in Speakeasy SDK when it tries to parse an empty response.
|
||||
*/
|
||||
export const ZSuccessResponseSchema = z.object({
|
||||
success: z.literal(true),
|
||||
});
|
||||
|
||||
export const ZGenericSuccessResponse = {
|
||||
success: true,
|
||||
} satisfies z.infer<typeof ZSuccessResponseSchema>;
|
||||
|
||||
export const ZDocumentTitleSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
|
||||
@ -21,10 +21,14 @@ export const createAttachmentRoute = authenticatedProcedure
|
||||
input: { envelopeId, label: data.label },
|
||||
});
|
||||
|
||||
await createAttachment({
|
||||
const attachment = await createAttachment({
|
||||
envelopeId,
|
||||
teamId,
|
||||
userId,
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
id: attachment.id,
|
||||
};
|
||||
});
|
||||
|
||||
@ -20,7 +20,9 @@ export const ZCreateAttachmentRequestSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZCreateAttachmentResponseSchema = z.void();
|
||||
export const ZCreateAttachmentResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type TCreateAttachmentRequest = z.infer<typeof ZCreateAttachmentRequestSchema>;
|
||||
export type TCreateAttachmentResponse = z.infer<typeof ZCreateAttachmentResponseSchema>;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { deleteAttachment } from '@documenso/lib/server-only/envelope-attachment/delete-attachment';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../../schema';
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZDeleteAttachmentRequestSchema,
|
||||
@ -26,4 +27,6 @@ export const deleteAttachmentRoute = authenticatedProcedure
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../../schema';
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const deleteAttachmentMeta: TrpcRouteMeta = {
|
||||
@ -16,7 +17,7 @@ export const ZDeleteAttachmentRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeleteAttachmentResponseSchema = z.void();
|
||||
export const ZDeleteAttachmentResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TDeleteAttachmentRequest = z.infer<typeof ZDeleteAttachmentRequestSchema>;
|
||||
export type TDeleteAttachmentResponse = z.infer<typeof ZDeleteAttachmentResponseSchema>;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { updateAttachment } from '@documenso/lib/server-only/envelope-attachment/update-attachment';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../../schema';
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZUpdateAttachmentRequestSchema,
|
||||
@ -27,4 +28,6 @@ export const updateAttachmentRoute = authenticatedProcedure
|
||||
teamId,
|
||||
data,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../../schema';
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const updateAttachmentMeta: TrpcRouteMeta = {
|
||||
@ -20,7 +21,7 @@ export const ZUpdateAttachmentRequestSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateAttachmentResponseSchema = z.void();
|
||||
export const ZUpdateAttachmentResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TUpdateAttachmentRequest = z.infer<typeof ZUpdateAttachmentRequestSchema>;
|
||||
export type TUpdateAttachmentResponse = z.infer<typeof ZUpdateAttachmentResponseSchema>;
|
||||
|
||||
@ -11,6 +11,7 @@ export const createEnvelopeItemsMeta: TrpcRouteMeta = {
|
||||
method: 'POST',
|
||||
path: '/envelope/item/create-many',
|
||||
summary: 'Create envelope items',
|
||||
contentTypes: ['multipart/form-data'],
|
||||
description: 'Create multiple envelope items for an envelope',
|
||||
tags: ['Envelope Items'],
|
||||
},
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
ZClampedFieldWidthSchema,
|
||||
ZFieldPageNumberSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
|
||||
import { zodFormData } from '../../utils/zod-form-data';
|
||||
import {
|
||||
@ -55,7 +55,7 @@ export const ZCreateEnvelopePayloadSchema = z.object({
|
||||
recipients: z
|
||||
.array(
|
||||
ZCreateRecipientSchema.extend({
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
fields: ZEnvelopeFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
identifier: z
|
||||
.union([z.string(), z.number()])
|
||||
|
||||
@ -5,6 +5,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
|
||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../schema';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZDeleteEnvelopeItemRequestSchema,
|
||||
@ -100,4 +101,6 @@ export const deleteEnvelopeItemRoute = authenticatedProcedure
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../schema';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const deleteEnvelopeItemMeta: TrpcRouteMeta = {
|
||||
@ -17,7 +18,7 @@ export const ZDeleteEnvelopeItemRequestSchema = z.object({
|
||||
envelopeItemId: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeleteEnvelopeItemResponseSchema = z.void();
|
||||
export const ZDeleteEnvelopeItemResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TDeleteEnvelopeItemRequest = z.infer<typeof ZDeleteEnvelopeItemRequestSchema>;
|
||||
export type TDeleteEnvelopeItemResponse = z.infer<typeof ZDeleteEnvelopeItemResponseSchema>;
|
||||
|
||||
@ -6,6 +6,7 @@ import { deleteDocument } from '@documenso/lib/server-only/document/delete-docum
|
||||
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../schema';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZDeleteEnvelopeRequestSchema,
|
||||
@ -65,4 +66,6 @@ export const deleteEnvelopeRoute = authenticatedProcedure
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../schema';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const deleteEnvelopeMeta: TrpcRouteMeta = {
|
||||
@ -15,7 +16,7 @@ export const ZDeleteEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeleteEnvelopeResponseSchema = z.void();
|
||||
export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TDeleteEnvelopeRequest = z.infer<typeof ZDeleteEnvelopeRequestSchema>;
|
||||
export type TDeleteEnvelopeResponse = z.infer<typeof ZDeleteEnvelopeResponseSchema>;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { updateDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../schema';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZDistributeEnvelopeRequestSchema,
|
||||
@ -53,4 +54,6 @@ export const distributeEnvelopeRoute = authenticatedProcedure
|
||||
teamId,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
import { ZDocumentMetaUpdateSchema } from '@documenso/lib/types/document-meta';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../schema';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const distributeEnvelopeMeta: TrpcRouteMeta = {
|
||||
@ -30,7 +31,7 @@ export const ZDistributeEnvelopeRequestSchema = z.object({
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const ZDistributeEnvelopeResponseSchema = z.void();
|
||||
export const ZDistributeEnvelopeResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TDistributeEnvelopeRequest = z.infer<typeof ZDistributeEnvelopeRequestSchema>;
|
||||
export type TDistributeEnvelopeResponse = z.infer<typeof ZDistributeEnvelopeResponseSchema>;
|
||||
|
||||
@ -19,5 +19,6 @@ export const downloadEnvelopeItemRoute = authenticatedProcedure
|
||||
},
|
||||
});
|
||||
|
||||
// This endpoint is purely for V2 API, which is implemented in the Hono remix server.
|
||||
throw new Error('NOT_IMPLEMENTED');
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
ZFieldPageNumberSchema,
|
||||
ZFieldSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
@ -16,14 +16,13 @@ export const createEnvelopeFieldsMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/field/create-many',
|
||||
contentTypes: ['multipart/form-data'],
|
||||
summary: 'Create envelope fields',
|
||||
description: 'Create multiple fields for an envelope',
|
||||
tags: ['Envelope Fields'],
|
||||
},
|
||||
};
|
||||
|
||||
const ZCreateFieldSchema = ZFieldAndMetaSchema.and(
|
||||
const ZCreateFieldSchema = ZEnvelopeFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
recipientId: z.number().describe('The ID of the recipient to create the field for'),
|
||||
envelopeItemId: z
|
||||
|
||||
@ -7,6 +7,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../../schema';
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZDeleteEnvelopeFieldRequestSchema,
|
||||
@ -115,4 +116,6 @@ export const deleteEnvelopeFieldRoute = authenticatedProcedure
|
||||
|
||||
return deletedField;
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../../schema';
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const deleteEnvelopeFieldMeta: TrpcRouteMeta = {
|
||||
@ -16,7 +17,7 @@ export const ZDeleteEnvelopeFieldRequestSchema = z.object({
|
||||
fieldId: z.number(),
|
||||
});
|
||||
|
||||
export const ZDeleteEnvelopeFieldResponseSchema = z.void();
|
||||
export const ZDeleteEnvelopeFieldResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TDeleteEnvelopeFieldRequest = z.infer<typeof ZDeleteEnvelopeFieldRequestSchema>;
|
||||
export type TDeleteEnvelopeFieldResponse = z.infer<typeof ZDeleteEnvelopeFieldResponseSchema>;
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
ZFieldPageNumberSchema,
|
||||
ZFieldSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
@ -22,7 +22,7 @@ export const updateEnvelopeFieldsMeta: TrpcRouteMeta = {
|
||||
},
|
||||
};
|
||||
|
||||
const ZUpdateFieldSchema = ZFieldAndMetaSchema.and(
|
||||
const ZUpdateFieldSchema = ZEnvelopeFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
id: z.number().describe('The ID of the field to update.'),
|
||||
envelopeItemId: z
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/delete-envelope-recipient';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../../schema';
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZDeleteEnvelopeRecipientRequestSchema,
|
||||
@ -27,4 +28,6 @@ export const deleteEnvelopeRecipientRoute = authenticatedProcedure
|
||||
recipientId,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../../schema';
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const deleteEnvelopeRecipientMeta: TrpcRouteMeta = {
|
||||
@ -16,7 +17,7 @@ export const ZDeleteEnvelopeRecipientRequestSchema = z.object({
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export const ZDeleteEnvelopeRecipientResponseSchema = z.void();
|
||||
export const ZDeleteEnvelopeRecipientResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TDeleteEnvelopeRecipientRequest = z.infer<typeof ZDeleteEnvelopeRecipientRequestSchema>;
|
||||
export type TDeleteEnvelopeRecipientResponse = z.infer<
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../schema';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZRedistributeEnvelopeRequestSchema,
|
||||
@ -32,4 +33,6 @@ export const redistributeEnvelopeRoute = authenticatedProcedure
|
||||
recipients,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../schema';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const redistributeEnvelopeMeta: TrpcRouteMeta = {
|
||||
@ -21,7 +22,7 @@ export const ZRedistributeEnvelopeRequestSchema = z.object({
|
||||
.describe('The IDs of the recipients to redistribute the envelope to.'),
|
||||
});
|
||||
|
||||
export const ZRedistributeEnvelopeResponseSchema = z.void();
|
||||
export const ZRedistributeEnvelopeResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TRedistributeEnvelopeRequest = z.infer<typeof ZRedistributeEnvelopeRequestSchema>;
|
||||
export type TRedistributeEnvelopeResponse = z.infer<typeof ZRedistributeEnvelopeResponseSchema>;
|
||||
|
||||
@ -8,6 +8,7 @@ import { createEnvelopeItemsRoute } from './create-envelope-items';
|
||||
import { deleteEnvelopeRoute } from './delete-envelope';
|
||||
import { deleteEnvelopeItemRoute } from './delete-envelope-item';
|
||||
import { distributeEnvelopeRoute } from './distribute-envelope';
|
||||
import { downloadEnvelopeItemRoute } from './download-envelope-item';
|
||||
import { duplicateEnvelopeRoute } from './duplicate-envelope';
|
||||
import { createEnvelopeFieldsRoute } from './envelope-fields/create-envelope-fields';
|
||||
import { deleteEnvelopeFieldRoute } from './envelope-fields/delete-envelope-field';
|
||||
@ -46,6 +47,7 @@ export const envelopeRouter = router({
|
||||
createMany: createEnvelopeItemsRoute,
|
||||
updateMany: updateEnvelopeItemsRoute,
|
||||
delete: deleteEnvelopeItemRoute,
|
||||
download: downloadEnvelopeItemRoute,
|
||||
},
|
||||
recipient: {
|
||||
get: getEnvelopeRecipientRoute,
|
||||
|
||||
@ -10,7 +10,7 @@ import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-field
|
||||
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
|
||||
import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
|
||||
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
ZCreateDocumentFieldRequestSchema,
|
||||
|
||||
@ -7,6 +7,7 @@ import { getFolderBreadcrumbs } from '@documenso/lib/server-only/folder/get-fold
|
||||
import { getFolderById } from '@documenso/lib/server-only/folder/get-folder-by-id';
|
||||
import { updateFolder } from '@documenso/lib/server-only/folder/update-folder';
|
||||
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
|
||||
import { authenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZCreateFolderRequestSchema,
|
||||
@ -16,10 +17,8 @@ import {
|
||||
ZFindFoldersInternalResponseSchema,
|
||||
ZFindFoldersRequestSchema,
|
||||
ZFindFoldersResponseSchema,
|
||||
ZGenericSuccessResponse,
|
||||
ZGetFoldersResponseSchema,
|
||||
ZGetFoldersSchema,
|
||||
ZSuccessResponseSchema,
|
||||
ZUpdateFolderRequestSchema,
|
||||
ZUpdateFolderResponseSchema,
|
||||
} from './schema';
|
||||
|
||||
@ -5,19 +5,6 @@ import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/typ
|
||||
import { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
|
||||
|
||||
/**
|
||||
* Required for empty responses since we currently can't 201 requests for our openapi setup.
|
||||
*
|
||||
* Without this it will throw an error in Speakeasy SDK when it tries to parse an empty response.
|
||||
*/
|
||||
export const ZSuccessResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
export const ZGenericSuccessResponse = {
|
||||
success: true,
|
||||
} satisfies z.infer<typeof ZSuccessResponseSchema>;
|
||||
|
||||
export const ZFolderSchema = FolderSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
|
||||
@ -9,7 +9,7 @@ import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-
|
||||
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
|
||||
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
|
||||
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import { findRecipientSuggestionsRoute } from './find-recipient-suggestions';
|
||||
import {
|
||||
|
||||
14
packages/trpc/server/schema.ts
Normal file
14
packages/trpc/server/schema.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Required for empty responses since we currently can't 201 requests for our openapi setup.
|
||||
*
|
||||
* Without this it will throw an error in Speakeasy SDK when it tries to parse an empty response.
|
||||
*/
|
||||
export const ZSuccessResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
export const ZGenericSuccessResponse = {
|
||||
success: true,
|
||||
} satisfies z.infer<typeof ZSuccessResponseSchema>;
|
||||
@ -28,7 +28,7 @@ import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
|
||||
import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients';
|
||||
import { mapEnvelopeToTemplateLite } from '@documenso/lib/utils/templates';
|
||||
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
|
||||
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZBulkSendTemplateMutationSchema,
|
||||
@ -177,7 +177,19 @@ export const templateRouter = router({
|
||||
|
||||
const { payload, file } = input;
|
||||
|
||||
const { title, folderId } = payload;
|
||||
const {
|
||||
title,
|
||||
folderId,
|
||||
externalId,
|
||||
visibility,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
publicTitle,
|
||||
publicDescription,
|
||||
type,
|
||||
meta,
|
||||
attachments,
|
||||
} = payload;
|
||||
|
||||
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(file);
|
||||
|
||||
@ -194,13 +206,22 @@ export const templateRouter = router({
|
||||
data: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title,
|
||||
folderId,
|
||||
envelopeItems: [
|
||||
{
|
||||
documentDataId: templateDocumentDataId,
|
||||
},
|
||||
],
|
||||
folderId,
|
||||
externalId: externalId ?? undefined,
|
||||
visibility,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
templateType: type,
|
||||
publicTitle,
|
||||
publicDescription,
|
||||
},
|
||||
meta,
|
||||
attachments,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
|
||||
@ -79,16 +79,6 @@ export const ZTemplateMetaUpsertSchema = z.object({
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ZCreateTemplatePayloadSchema = z.object({
|
||||
title: z.string().min(1).trim(),
|
||||
folderId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZCreateTemplateMutationSchema = zodFormData({
|
||||
payload: zfd.json(ZCreateTemplatePayloadSchema),
|
||||
file: zfd.file(),
|
||||
});
|
||||
|
||||
export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({
|
||||
directRecipientName: z.string().max(255).optional(),
|
||||
directRecipientEmail: z.string().email().max(254),
|
||||
@ -234,6 +224,13 @@ export const ZCreateTemplateResponseSchema = z.object({
|
||||
id: z.number(),
|
||||
});
|
||||
|
||||
export const ZCreateTemplatePayloadSchema = ZCreateTemplateV2RequestSchema;
|
||||
|
||||
export const ZCreateTemplateMutationSchema = zodFormData({
|
||||
payload: zfd.json(ZCreateTemplatePayloadSchema),
|
||||
file: zfd.file(),
|
||||
});
|
||||
|
||||
export const ZUpdateTemplateRequestSchema = z.object({
|
||||
templateId: z.number(),
|
||||
data: z
|
||||
@ -280,7 +277,7 @@ export const ZBulkSendTemplateMutationSchema = z.object({
|
||||
sendImmediately: z.boolean(),
|
||||
});
|
||||
|
||||
export type TCreateTemplatePayloadSchema = z.infer<typeof ZCreateTemplatePayloadSchema>;
|
||||
export type TCreateTemplatePayloadSchema = z.input<typeof ZCreateTemplatePayloadSchema>;
|
||||
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
|
||||
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
|
||||
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
|
||||
|
||||
@ -24,11 +24,17 @@ const getMultipartBody = async (req: Request) => {
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
for (const key of formData.keys()) {
|
||||
const values = formData.getAll(key);
|
||||
for (const [key, value] of formData.entries()) {
|
||||
// !: Handles cases where our generated SDKs send key[] syntax for arrays.
|
||||
const normalizedKey = key.endsWith('[]') ? key.slice(0, -2) : key;
|
||||
|
||||
// Return array for multiple values, single value otherwise (matches URL-encoded behavior)
|
||||
data[key] = values.length > 1 ? values : values[0];
|
||||
if (data[normalizedKey] === undefined) {
|
||||
data[normalizedKey] = value;
|
||||
} else if (Array.isArray(data[normalizedKey])) {
|
||||
data[normalizedKey].push(value);
|
||||
} else {
|
||||
data[normalizedKey] = [data[normalizedKey], value];
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
5
packages/tsconfig/process-env.d.ts
vendored
5
packages/tsconfig/process-env.d.ts
vendored
@ -80,11 +80,6 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_INNGEST_APP_ID?: string;
|
||||
NEXT_PRIVATE_INNGEST_EVENT_KEY?: string;
|
||||
|
||||
/**
|
||||
* Google Generative AI (Gemini)
|
||||
*/
|
||||
GOOGLE_GENERATIVE_AI_API_KEY?: string;
|
||||
|
||||
POSTGRES_URL?: string;
|
||||
DATABASE_URL?: string;
|
||||
POSTGRES_PRISMA_URL?: string;
|
||||
|
||||
@ -197,56 +197,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes edgeGlow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.edge-glow {
|
||||
animation: edgeGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.edge-glow-top {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(162, 231, 113, 0.4) 0%,
|
||||
rgba(162, 231, 113, 0.2) 20%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.edge-glow-right {
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
rgba(162, 231, 113, 0.4) 0%,
|
||||
rgba(162, 231, 113, 0.2) 20%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.edge-glow-bottom {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(162, 231, 113, 0.4) 0%,
|
||||
rgba(162, 231, 113, 0.2) 20%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.edge-glow-left {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(162, 231, 113, 0.4) 0%,
|
||||
rgba(162, 231, 113, 0.2) 20%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Custom CSS for printing reports
|
||||
* - Sets page margins to 0.5 inches
|
||||
|
||||
@ -46,7 +46,6 @@
|
||||
"NEXT_PUBLIC_WEBAPP_URL",
|
||||
"NEXT_PRIVATE_INTERNAL_WEBAPP_URL",
|
||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||
"NEXT_PUBLIC_AI_DEBUG_PREVIEW",
|
||||
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
||||
"NEXT_PUBLIC_DISABLE_SIGNUP",
|
||||
"NEXT_PRIVATE_PLAIN_API_KEY",
|
||||
|
||||
Reference in New Issue
Block a user