From a0a3e7fb9376624cccabf794bc68b0792ed4daaa Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 19 Nov 2025 08:53:57 +0000 Subject: [PATCH] chore: review --- .../server/api/document-analysis/index.ts | 44 ++++++++++++++-- .../server/api/document-analysis/types.ts | 39 +++----------- .../server-only/pdf/render-pdf-to-image.ts | 22 +++++++- packages/lib/types/document-analysis.ts | 51 +++++++++++++------ 4 files changed, 103 insertions(+), 53 deletions(-) diff --git a/apps/remix/server/api/document-analysis/index.ts b/apps/remix/server/api/document-analysis/index.ts index b180599dd..687e84ad8 100644 --- a/apps/remix/server/api/document-analysis/index.ts +++ b/apps/remix/server/api/document-analysis/index.ts @@ -22,6 +22,7 @@ import { type TDetectedRecipient, ZAnalyzeRecipientsRequestSchema, ZDetectFormFieldsRequestSchema, + ZDetectFormFieldsResponseSchema, ZDetectedFormFieldSchema, ZDetectedRecipientLLMSchema, } from './types'; @@ -180,6 +181,13 @@ export const aiRoute = new Hono() try { const { user } = await getSession(c.req.raw); + if (!user.emailVerified) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Email verification required', + userMessage: 'Please verify your email to use AI features', + }); + } + const body = await c.req.json(); const parsed = ZDetectFormFieldsRequestSchema.safeParse(body); @@ -259,15 +267,25 @@ export const aiRoute = new Hono() ); const detectedFields: TDetectFormFieldsResponse = []; + const failedPages: number[] = []; + 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); + failedPages.push(pageNumber); } } + if (failedPages.length > 0) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: `Failed to detect fields on pages: ${failedPages.join(', ')}`, + userMessage: 'We could not detect fields on some pages. Please try again.', + }); + } + if (env('NEXT_PUBLIC_AI_DEBUG_PREVIEW') === 'true') { const debugDir = join(process.cwd(), '..', '..', 'packages', 'assets', 'ai-previews'); await mkdir(debugDir, { recursive: true }); @@ -378,7 +396,9 @@ export const aiRoute = new Hono() } } - return c.json(detectedFields); + const validatedResponse = ZDetectFormFieldsResponseSchema.parse(detectedFields); + + return c.json(validatedResponse); } catch (error) { if (error instanceof AppError) { throw error; @@ -396,6 +416,13 @@ export const aiRoute = new Hono() try { const { user } = await getSession(c.req.raw); + if (!user.emailVerified) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Email verification required', + userMessage: 'Please verify your email to use AI features', + }); + } + const body = await c.req.json(); const parsed = ZAnalyzeRecipientsRequestSchema.safeParse(body); @@ -455,9 +482,13 @@ export const aiRoute = new Hono() const allRecipients: TDetectedRecipient[] = []; let recipientIndex = 1; - for (const result of results) { + const failedPages: number[] = []; + + for (const [index, result] of results.entries()) { if (result.status !== 'fulfilled') { - console.error('Failed to analyze recipients on a page:', result.reason); + const pageNumber = pagesToAnalyze[index]?.pageNumber ?? index + 1; + console.error(`Failed to analyze recipients on page ${pageNumber}:`, result.reason); + failedPages.push(pageNumber); continue; } @@ -479,6 +510,13 @@ export const aiRoute = new Hono() allRecipients.push(...recipientsWithEmails); } + if (failedPages.length > 0) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: `Failed to analyze recipients on pages: ${failedPages.join(', ')}`, + userMessage: 'We could not analyze recipients on some pages. Please try again.', + }); + } + return c.json(allRecipients); } catch (error) { if (error instanceof AppError) { diff --git a/apps/remix/server/api/document-analysis/types.ts b/apps/remix/server/api/document-analysis/types.ts index 67bba42b0..18f04c4d0 100644 --- a/apps/remix/server/api/document-analysis/types.ts +++ b/apps/remix/server/api/document-analysis/types.ts @@ -1,6 +1,11 @@ import { z } from 'zod'; -import type { TDetectedFormField } from '@documenso/lib/types/document-analysis'; +import { + type TDetectedFormField, + ZDetectedFormFieldSchema, +} from '@documenso/lib/types/document-analysis'; + +export { ZDetectedFormFieldSchema }; export const ZGenerateTextRequestSchema = z.object({ prompt: z.string().min(1, 'Prompt is required').max(5000, 'Prompt is too long'), @@ -13,38 +18,6 @@ export const ZGenerateTextResponseSchema = z.object({ export type TGenerateTextRequest = z.infer; export type TGenerateTextResponse = z.infer; -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'), - recipientId: z - .number() - .int() - .describe( - 'ID of the recipient (from the provided envelope recipients) who should own the field', - ), -}); - export const ZDetectFormFieldsRequestSchema = z.object({ envelopeId: z.string().min(1, { message: 'Envelope ID is required' }), }); diff --git a/packages/lib/server-only/pdf/render-pdf-to-image.ts b/packages/lib/server-only/pdf/render-pdf-to-image.ts index fb8c030bc..2ba67580d 100644 --- a/packages/lib/server-only/pdf/render-pdf-to-image.ts +++ b/packages/lib/server-only/pdf/render-pdf-to-image.ts @@ -28,7 +28,7 @@ export const renderPdfToImage = async (pdfBytes: Uint8Array) => { try { const scale = 2; - const pages = await Promise.all( + const results = await Promise.allSettled( Array.from({ length: pdf.numPages }, async (_, index) => { const pageNumber = index + 1; const page = await pdf.getPage(pageNumber); @@ -54,6 +54,26 @@ export const renderPdfToImage = async (pdfBytes: Uint8Array) => { }), ); + const pages = results + .filter( + ( + result, + ): result is PromiseFulfilledResult<{ + image: Buffer; + pageNumber: number; + width: number; + height: number; + }> => result.status === 'fulfilled', + ) + .map((result) => result.value); + + if (results.some((result) => result.status === 'rejected')) { + console.error( + 'Some pages failed to render:', + results.filter((result) => result.status === 'rejected').map((result) => result.reason), + ); + } + return pages; } finally { await pdf.destroy(); diff --git a/packages/lib/types/document-analysis.ts b/packages/lib/types/document-analysis.ts index d2ce79aca..ed5be6356 100644 --- a/packages/lib/types/document-analysis.ts +++ b/packages/lib/types/document-analysis.ts @@ -1,16 +1,35 @@ -export type TDetectedFormField = { - boundingBox: number[]; - label: - | 'SIGNATURE' - | 'INITIALS' - | 'NAME' - | 'EMAIL' - | 'DATE' - | 'TEXT' - | 'NUMBER' - | 'RADIO' - | 'CHECKBOX' - | 'DROPDOWN'; - pageNumber: number; - recipientId: number; -}; +import { z } from 'zod'; + +export const ZDetectedFormFieldSchema = z.object({ + boundingBox: z + .array(z.number().min(0).max(1000)) + .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'), + recipientId: z + .number() + .int() + .describe( + 'ID of the recipient (from the provided envelope recipients) who should own the field', + ), +}); + +export type TDetectedFormField = z.infer;