Compare commits

..

2 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan 142444835f chore: update tests 2025-10-24 12:54:08 +00:00
Ephraim Atta-Duncan 3ba2dd0613 fix: add 0-100 bounds validation for field coordinates 2025-10-24 12:11:11 +00:00
38 changed files with 435 additions and 1471 deletions
@@ -7,9 +7,9 @@ import { FilePlus, Loader } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -54,17 +54,13 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
setIsUploadingFile(true);
try {
const payload = {
const response = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
title: file.name,
templateDocumentDataId: response.id,
folderId: folderId,
} satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
});
toast({
title: _(msg`Template document uploaded`),
@@ -16,9 +16,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -62,18 +62,14 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
try {
setIsLoading(true);
const payload = {
const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
title: file.name,
timezone: userTimezone,
documentDataId: response.id,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
folderId: folderId ?? undefined,
} satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
});
void refreshLimits();
@@ -13,9 +13,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import {
@@ -73,18 +73,14 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
try {
setIsLoading(true);
const payload = {
const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
title: file.name,
documentDataId: response.id,
timezone: userTimezone,
folderId: folderId ?? undefined,
} satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
});
void refreshLimits();
@@ -14,9 +14,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import {
@@ -78,24 +78,35 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
try {
setIsLoading(true);
const payload = {
const result = await Promise.all(
files.map(async (file) => {
try {
const response = await putPdfFile(file);
return {
title: file.name,
documentDataId: response.id,
};
} catch (err) {
console.error(err);
throw new Error('Failed to upload document');
}
}),
);
const envelopeItemsToCreate = result.filter(
(item): item is { title: string; documentDataId: string } => item !== undefined,
);
const { id } = await createEnvelope({
folderId,
type,
title: files[0].name,
items: envelopeItemsToCreate,
meta: {
timezone: userTimezone,
},
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEnvelope(formData).catch((error) => {
}).catch((error) => {
console.error(error);
throw error;
@@ -10,9 +10,9 @@ import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -40,17 +40,13 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
try {
setIsLoading(true);
const payload = {
const documentData = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
title: file.name,
templateDocumentDataId: documentData.id,
folderId: folderId ?? undefined,
} satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
});
toast({
title: _(msg`Template uploaded`),
+1 -1
View File
@@ -1,10 +1,10 @@
import type { Context } from 'hono';
import { createOpenApiFetchHandler } from 'trpc-to-openapi';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
import { createOpenApiFetchHandler } from '@documenso/trpc/utils/openapi-fetch-handler';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
export const openApiTrpcServerHandler = async (c: Context) => {
+212 -608
View File
File diff suppressed because it is too large Load Diff
+5 -15
View File
@@ -44,7 +44,7 @@
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"@lingui/cli": "^5.2.0",
"@prisma/client": "^6.18.0",
"@prisma/client": "^6.8.2",
"dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0",
"eslint": "^8.40.0",
@@ -54,21 +54,11 @@
"nodemailer": "^6.10.1",
"playwright": "1.52.0",
"prettier": "^3.3.3",
"prisma": "^6.18.0",
"prisma": "^6.8.2",
"prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0",
"rimraf": "^5.0.1",
"turbo": "^1.9.3",
"@trpc/client": "11.7.0",
"@trpc/react-query": "11.7.0",
"@trpc/server": "11.7.0",
"superjson": "^2.2.5",
"trpc-to-openapi": "2.4.0",
"zod-openapi": "^4.2.4",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
"zod-prisma-types": "3.3.5",
"vite": "^6.3.5"
},
"name": "@documenso/root",
@@ -86,12 +76,12 @@
"mupdf": "^1.0.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "^3.25.76"
"zod": "3.24.1"
},
"overrides": {
"zod": "^3.25.76"
"zod": "3.24.1"
},
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}
+5 -5
View File
@@ -17,14 +17,14 @@
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@ts-rest/core": "^3.52.0",
"@ts-rest/open-api": "^3.52.0",
"@ts-rest/serverless": "^3.52.0",
"@ts-rest/core": "^3.30.5",
"@ts-rest/open-api": "^3.33.0",
"@ts-rest/serverless": "^3.30.5",
"@types/swagger-ui-react": "^5.18.0",
"luxon": "^3.4.0",
"superjson": "^2.2.5",
"superjson": "^1.13.1",
"swagger-ui-react": "^5.21.0",
"ts-pattern": "^5.0.5",
"zod": "^3.25.76"
"zod": "3.24.1"
}
}
+16 -4
View File
@@ -456,10 +456,22 @@ const ZCreateFieldSchema = z.object({
recipientId: z.number(),
type: z.nativeEnum(FieldType),
pageNumber: z.number(),
pageX: z.number(),
pageY: z.number(),
pageWidth: z.number(),
pageHeight: z.number(),
pageX: z
.number()
.min(0, 'Must be between 0-100 (percentage of page width)')
.max(100, 'Must be between 0-100 (percentage of page width)'),
pageY: z
.number()
.min(0, 'Must be between 0-100 (percentage of page height)')
.max(100, 'Must be between 0-100 (percentage of page height)'),
pageWidth: z
.number()
.min(0, 'Must be between 0-100 (percentage of page width)')
.max(100, 'Must be between 0-100 (percentage of page width)'),
pageHeight: z
.number()
.min(0, 'Must be between 0-100 (percentage of page height)')
.max(100, 'Must be between 0-100 (percentage of page height)'),
fieldMeta: ZFieldMetaSchema.openapi({}),
});
@@ -427,18 +427,18 @@ test.describe('Document API V2', () => {
field: {
recipientId: recipient!.id,
type: 'TEXT',
pageNumber: 791.77,
pageX: 7845.22,
pageY: 6843.16,
width: 3932.15,
height: 8879.89,
pageNumber: 1,
pageX: 25.5,
pageY: 30.2,
width: 40.0,
height: 15.5,
fieldMeta: { type: 'text', label: 'Test Field' },
},
},
});
expect(res.ok()).toBeFalsy();
expect([404, 401, 500]).toContain(res.status());
expect([400, 404, 401, 500]).toContain(res.status());
});
test('should allow authorized access to document field create endpoint', async ({
@@ -459,11 +459,11 @@ test.describe('Document API V2', () => {
field: {
recipientId: recipient!.id,
type: 'TEXT',
pageNumber: 791.77,
pageX: 7845.22,
pageY: 6843.16,
width: 3932.15,
height: 8879.89,
pageNumber: 1,
pageX: 25.5,
pageY: 30.2,
width: 40.0,
height: 15.5,
fieldMeta: { type: 'text', label: 'Test Field' },
},
},
@@ -494,21 +494,21 @@ test.describe('Document API V2', () => {
{
recipientId: recipient!.id,
type: 'TEXT',
pageNumber: 791.77,
pageX: 7845.22,
pageY: 6843.16,
width: 3932.15,
height: 8879.89,
pageNumber: 1,
pageX: 25.5,
pageY: 30.2,
width: 40.0,
height: 15.5,
fieldMeta: { type: 'text', label: 'First test field' },
},
{
recipientId: recipient!.id,
type: 'TEXT',
pageNumber: 791.77,
pageX: 845.22,
pageY: 843.16,
width: 932.15,
height: 879.89,
pageNumber: 1,
pageX: 50.0,
pageY: 60.0,
width: 35.0,
height: 12.0,
fieldMeta: { type: 'text', label: 'Second test field' },
},
],
@@ -538,21 +538,21 @@ test.describe('Document API V2', () => {
{
recipientId: recipient!.id,
type: 'TEXT',
pageNumber: 791.77,
pageX: 7845.22,
pageY: 6843.16,
width: 3932.15,
height: 8879.89,
pageNumber: 1,
pageX: 25.5,
pageY: 30.2,
width: 40.0,
height: 15.5,
fieldMeta: { type: 'text', label: 'First test field' },
},
{
recipientId: recipient!.id,
type: 'TEXT',
pageNumber: 791.77,
pageX: 845.22,
pageY: 843.16,
width: 932.15,
height: 879.89,
pageNumber: 1,
pageX: 50.0,
pageY: 60.0,
width: 35.0,
height: 12.0,
fieldMeta: { type: 'text', label: 'Second test field' },
},
],
@@ -892,11 +892,11 @@ test.describe('Document API V2', () => {
field: {
recipientId: recipient.id,
type: FieldType.TEXT,
pageNumber: 5735.12,
pageX: 936.28,
pageY: 594.41,
width: 589.39,
height: 122.23,
pageNumber: 1,
pageX: 35.0,
pageY: 45.0,
width: 30.0,
height: 10.0,
fieldMeta: { type: 'text', label: 'Test' },
},
},
@@ -931,11 +931,11 @@ test.describe('Document API V2', () => {
field: {
recipientId: recipient.id,
type: FieldType.TEXT,
pageNumber: 5735.12,
pageX: 936.28,
pageY: 594.41,
width: 589.39,
height: 122.23,
pageNumber: 1,
pageX: 35.0,
pageY: 45.0,
width: 30.0,
height: 10.0,
fieldMeta: { type: 'text', label: 'Test' },
},
},
+1 -1
View File
@@ -20,6 +20,6 @@
"luxon": "^3.5.0",
"nanoid": "^5.1.5",
"ts-pattern": "^5.0.5",
"zod": "^3.25.76"
"zod": "3.24.1"
}
}
+1 -1
View File
@@ -19,6 +19,6 @@
"micro": "^10.0.1",
"react": "^18",
"ts-pattern": "^5.0.5",
"zod": "^3.25.76"
"zod": "3.24.1"
}
}
+2 -2
View File
@@ -55,11 +55,11 @@
"skia-canvas": "^3.0.8",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"zod": "^3.25.76"
"zod": "3.24.1"
},
"devDependencies": {
"@playwright/browser-chromium": "1.52.0",
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}
}
}
@@ -16,16 +16,11 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type {
TDocumentAccessAuthTypes,
TDocumentActionAuthTypes,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
} from '../../types/document-auth';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@@ -39,25 +34,6 @@ import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
documentDataId: string;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
type CreateEnvelopeRecipientOptions = {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
fields?: CreateEnvelopeRecipientFieldOptions[];
};
export type CreateEnvelopeOptions = {
userId: number;
teamId: number;
@@ -80,7 +56,7 @@ export type CreateEnvelopeOptions = {
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
recipients?: CreateEnvelopeRecipientOptions[];
recipients?: TCreateEnvelopeRequest['recipients'];
folderId?: string;
};
attachments?: Array<{
+3 -12
View File
@@ -1,22 +1,13 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { AppError } from '../../errors/app-error';
import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer) => {
const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`);
const pdfDoc = await PDFDocument.load(pdf).catch(() => null);
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is not a valid PDF',
});
});
if (pdfDoc.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is encrypted',
});
if (!pdfDoc) {
return pdf;
}
removeOptionalContentGroups(pdfDoc);
+14 -4
View File
@@ -57,17 +57,27 @@ export const ZFieldPageNumberSchema = z
export const ZFieldPageXSchema = z
.number()
.min(0)
.min(0, 'Must be between 0-100 (percentage of page width)')
.max(100, 'Must be between 0-100 (percentage of page width)')
.describe('The X coordinate of where the field will be placed.');
export const ZFieldPageYSchema = z
.number()
.min(0)
.min(0, 'Must be between 0-100 (percentage of page height)')
.max(100, 'Must be between 0-100 (percentage of page height)')
.describe('The Y coordinate of where the field will be placed.');
export const ZFieldWidthSchema = z.number().min(1).describe('The width of the field.');
export const ZFieldWidthSchema = z
.number()
.min(0, 'Must be between 0-100 (percentage of page width)')
.max(100, 'Must be between 0-100 (percentage of page width)')
.describe('The width of the field.');
export const ZFieldHeightSchema = z.number().min(1).describe('The height of the field.');
export const ZFieldHeightSchema = z
.number()
.min(0, 'Must be between 0-100 (percentage of page height)')
.max(100, 'Must be between 0-100 (percentage of page height)')
.describe('The height of the field.');
// ---------------------------------------------
@@ -7,7 +7,6 @@ import { env } from '@documenso/lib/utils/env';
import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
import { uploadS3File } from './server-actions';
type File = {
@@ -44,28 +43,6 @@ export const putPdfFileServerSide = async (file: File) => {
return await createDocumentData({ type, data });
};
/**
* Uploads a pdf file and normalizes it.
*/
export const putNormalizedPdfFileServerSide = async (file: File) => {
const buffer = Buffer.from(await file.arrayBuffer());
const normalized = await normalizePdf(buffer);
const fileName = file.name.endsWith('.pdf') ? file.name : `${file.name}.pdf`;
const documentData = await putFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalized),
});
return await createDocumentData({
type: documentData.type,
data: documentData.data,
});
};
/**
* Uploads a file to the appropriate storage location.
*/
+4 -4
View File
@@ -21,14 +21,14 @@
"seed": "tsx ./seed-database.ts"
},
"dependencies": {
"@prisma/client": "^6.18.0",
"@prisma/client": "^6.8.2",
"kysely": "0.26.3",
"prisma": "^6.18.0",
"prisma": "^6.8.2",
"prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0",
"prisma-json-types-generator": "^3.6.2",
"prisma-json-types-generator": "^3.2.2",
"ts-pattern": "^5.0.6",
"zod-prisma-types": "3.3.5"
"zod-prisma-types": "3.2.4"
},
"devDependencies": {
"dotenv": "^16.5.0",
+2 -2
View File
@@ -134,8 +134,8 @@ model Passkey {
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
lastUsedAt DateTime?
credentialId Bytes /// @zod.custom.use(z.instanceof(Uint8Array))
credentialPublicKey Bytes /// @zod.custom.use(z.instanceof(Uint8Array))
credentialId Bytes
credentialPublicKey Bytes
counter BigInt
credentialDeviceType String
credentialBackedUp Boolean
+5 -11
View File
@@ -1,23 +1,17 @@
import {
createTRPCClient,
httpBatchLink,
httpLink,
isNonJsonSerializable,
splitLink,
} from '@trpc/client';
import { createTRPCClient, httpBatchLink, httpLink, splitLink } from '@trpc/client';
import SuperJSON from 'superjson';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
import type { AppRouter } from '../server/router';
import { dataTransformer } from '../utils/data-transformer';
export const trpc = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.context.skipBatch === true || isNonJsonSerializable(op.input),
condition: (op) => op.context.skipBatch === true,
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: dataTransformer,
transformer: SuperJSON,
headers: (opts) => {
if (typeof opts.op.context.teamId === 'string') {
return {
@@ -30,7 +24,7 @@ export const trpc = createTRPCClient<AppRouter>({
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: dataTransformer,
transformer: SuperJSON,
headers: (opts) => {
const operationWithTeamId = opts.opList.find(
(op) => op.context.teamId && typeof op.context.teamId === 'string',
+8 -14
View File
@@ -12,21 +12,15 @@
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@tanstack/react-query": "5.90.5",
"@trpc/client": "11.7.0",
"@trpc/react-query": "11.7.0",
"@trpc/server": "11.7.0",
"@ts-rest/core": "^3.52.0",
"formidable": "^3.5.4",
"@tanstack/react-query": "5.59.15",
"@trpc/client": "11.0.0-rc.648",
"@trpc/react-query": "11.0.0-rc.648",
"@trpc/server": "11.0.0-rc.648",
"@ts-rest/core": "^3.30.5",
"luxon": "^3.4.0",
"superjson": "^2.2.5",
"trpc-to-openapi": "2.4.0",
"superjson": "^1.13.1",
"trpc-to-openapi": "2.0.4",
"ts-pattern": "^5.0.5",
"zod": "^3.25.76",
"zod-form-data": "^2.0.8",
"zod-openapi": "^4.2.4"
},
"devDependencies": {
"@types/formidable": "^3.4.6"
"zod": "3.24.1"
}
}
+5 -5
View File
@@ -1,13 +1,13 @@
import { useMemo, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, httpLink, isNonJsonSerializable, splitLink } from '@trpc/client';
import { httpBatchLink, httpLink, splitLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import SuperJSON from 'superjson';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
import type { AppRouter } from '../server/router';
import { dataTransformer } from '../utils/data-transformer';
export { getQueryKey } from '@trpc/react-query';
@@ -44,16 +44,16 @@ export function TrpcProvider({ children, headers }: TrpcProviderProps) {
trpc.createClient({
links: [
splitLink({
condition: (op) => op.context.skipBatch === true || isNonJsonSerializable(op.input),
condition: (op) => op.context.skipBatch === true,
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
headers,
transformer: dataTransformer,
transformer: SuperJSON,
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers,
transformer: dataTransformer,
transformer: SuperJSON,
}),
}),
],
@@ -1,136 +0,0 @@
import { EnvelopeType } from '@prisma/client';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateDocumentFormDataRequestSchema,
ZCreateDocumentFormDataResponseSchema,
createDocumentFormDataMeta,
} from './create-document-formdata.types';
/**
* Temporary endpoint for V2 Beta until we allow passthrough documents on create.
*
* @public
*/
export const createDocumentFormDataRoute = authenticatedProcedure
.meta(createDocumentFormDataMeta)
.input(ZCreateDocumentFormDataRequestSchema)
.output(ZCreateDocumentFormDataResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { payload, file } = input;
const {
title,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
recipients,
meta,
folderId,
attachments,
} = payload;
const { remaining } = await getServerLimits({ userId: user.id, teamId });
if (remaining.documents <= 0) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have reached your document limit for this month. Please upgrade your plan.',
statusCode: 400,
});
}
const documentData = await putPdfFileServerSide(file);
const createdEnvelope = await createEnvelope({
userId: ctx.user.id,
teamId,
normalizePdf: false, // Not normalizing because of presigned URL.
internalVersion: 1,
data: {
type: EnvelopeType.DOCUMENT,
title,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
recipients: (recipients || []).map((recipient) => ({
...recipient,
fields: (recipient.fields || []).map((field) => ({
...field,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
documentDataId: documentData.id,
})),
})),
folderId,
envelopeItems: [
{
// If you ever allow more than 1 in this endpoint, make sure to use `maximumEnvelopeItemCount` to limit it.
documentDataId: documentData.id,
},
],
},
attachments,
meta: {
...meta,
emailSettings: meta?.emailSettings ?? undefined,
},
requestMetadata: ctx.metadata,
});
const envelopeItems = await prisma.envelopeItem.findMany({
where: {
envelopeId: createdEnvelope.id,
},
include: {
documentData: true,
},
});
const legacyDocumentId = mapSecondaryIdToDocumentId(createdEnvelope.secondaryId);
const firstDocumentData = envelopeItems[0].documentData;
if (!firstDocumentData) {
throw new Error('Document data not found');
}
return {
document: {
...createdEnvelope,
envelopeId: createdEnvelope.id,
documentDataId: firstDocumentData.id,
documentData: {
...firstDocumentData,
envelopeItemId: envelopeItems[0].id,
},
documentMeta: {
...createdEnvelope.documentMeta,
documentId: legacyDocumentId,
},
id: legacyDocumentId,
fields: createdEnvelope.fields.map((field) => ({
...field,
documentId: legacyDocumentId,
templateId: null,
})),
recipients: createdEnvelope.recipients.map((recipient) => ({
...recipient,
documentId: legacyDocumentId,
templateId: null,
})),
},
folder: createdEnvelope.folder, // Todo: Remove this prior to api-v2 release.
};
});
@@ -1,97 +0,0 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZDocumentSchema } from '@documenso/lib/types/document';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import {
ZFieldHeightSchema,
ZFieldPageNumberSchema,
ZFieldPageXSchema,
ZFieldPageYSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zodFormData } from '../../utils/zod-form-data';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
import {
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
} from './schema';
export const createDocumentFormDataMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/document/create/formdata',
contentTypes: ['multipart/form-data'],
summary: 'Create document',
description: 'Create a document using form data.',
tags: ['Document'],
},
};
const ZCreateDocumentFormDataPayloadRequestSchema = z.object({
title: ZDocumentTitleSchema,
externalId: ZDocumentExternalIdSchema.optional(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
formValues: ZDocumentFormValuesSchema.optional(),
folderId: z
.string()
.describe(
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
recipients: z
.array(
ZCreateRecipientSchema.extend({
fields: ZFieldAndMetaSchema.and(
z.object({
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
}),
)
.array()
.optional(),
}),
)
.optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
meta: ZDocumentMetaCreateSchema.optional(),
});
// !: Can't use zfd.formData() here because it receives `undefined`
// !: somewhere in the pipeline of our openapi schema generation and throws
// !: an error.
export const ZCreateDocumentFormDataRequestSchema = zodFormData({
payload: zfd.json(ZCreateDocumentFormDataPayloadRequestSchema),
file: zfd.file(),
});
export const ZCreateDocumentFormDataResponseSchema = z.object({
document: ZDocumentSchema,
});
export type TCreateDocumentFormDataRequest = z.infer<typeof ZCreateDocumentFormDataRequestSchema>;
export type TCreateDocumentFormDataResponse = z.infer<typeof ZCreateDocumentFormDataResponseSchema>;
@@ -3,7 +3,6 @@ import { EnvelopeType } from '@prisma/client';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { authenticatedProcedure } from '../trpc';
@@ -17,12 +16,7 @@ export const createDocumentRoute = authenticatedProcedure
.output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { payload, file } = input;
const { title, timezone, folderId, attachments } = payload;
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
const { title, documentDataId, timezone, folderId, attachments } = input;
ctx.logger.info({
input: {
@@ -61,7 +55,6 @@ export const createDocumentRoute = authenticatedProcedure
});
return {
envelopeId: document.id,
legacyDocumentId: mapSecondaryIdToDocumentId(document.secondaryId),
};
});
@@ -1,27 +1,23 @@
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { zodFormData } from '../../utils/zod-form-data';
import type { TrpcRouteMeta } from '../trpc';
import { ZDocumentTitleSchema } from './schema';
// Currently not in use until we allow passthrough documents on create.
export const createDocumentMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/document/create',
contentTypes: ['multipart/form-data'],
summary: 'Create document',
description: 'Create a document using form data.',
tags: ['Document'],
},
};
// export const createDocumentMeta: TrpcRouteMeta = {
// openapi: {
// method: 'POST',
// path: '/document/create',
// summary: 'Create document',
// tags: ['Document'],
// },
// };
export const ZCreateDocumentPayloadSchema = z.object({
export const ZCreateDocumentRequestSchema = z.object({
title: ZDocumentTitleSchema,
documentDataId: z.string().min(1),
timezone: ZDocumentMetaTimezoneSchema.optional(),
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
attachments: z
@@ -35,16 +31,9 @@ export const ZCreateDocumentPayloadSchema = z.object({
.optional(),
});
export const ZCreateDocumentRequestSchema = zodFormData({
payload: zfd.json(ZCreateDocumentPayloadSchema),
file: zfd.file(),
});
export const ZCreateDocumentResponseSchema = z.object({
envelopeId: z.string(),
legacyDocumentId: z.number(),
});
export type TCreateDocumentPayloadSchema = z.infer<typeof ZCreateDocumentPayloadSchema>;
export type TCreateDocumentRequest = z.infer<typeof ZCreateDocumentRequestSchema>;
export type TCreateDocumentResponse = z.infer<typeof ZCreateDocumentResponseSchema>;
@@ -5,7 +5,6 @@ import { deleteAttachmentRoute } from './attachment/delete-attachment';
import { findAttachmentsRoute } from './attachment/find-attachments';
import { updateAttachmentRoute } from './attachment/update-attachment';
import { createDocumentRoute } from './create-document';
import { createDocumentFormDataRoute } from './create-document-formdata';
import { createDocumentTemporaryRoute } from './create-document-temporary';
import { deleteDocumentRoute } from './delete-document';
import { distributeDocumentRoute } from './distribute-document';
@@ -41,7 +40,6 @@ export const documentRouter = router({
// Temporary v2 beta routes to be removed once V2 is fully released.
download: downloadDocumentRoute,
createDocumentTemporary: createDocumentTemporaryRoute,
createDocumentFormData: createDocumentFormDataRoute,
// Internal document routes for custom frontend requests.
getDocumentByToken: getDocumentByTokenRoute,
@@ -1,7 +1,6 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { authenticatedProcedure } from '../trpc';
import {
@@ -14,9 +13,6 @@ export const createEnvelopeRoute = authenticatedProcedure
.output(ZCreateEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { payload, files } = input;
const {
title,
type,
@@ -26,9 +22,10 @@ export const createEnvelopeRoute = authenticatedProcedure
globalActionAuth,
recipients,
folderId,
items,
meta,
attachments,
} = payload;
} = input;
ctx.logger.info({
input: {
@@ -48,62 +45,13 @@ export const createEnvelopeRoute = authenticatedProcedure
});
}
if (files.length > maximumEnvelopeItemCount) {
if (items.length > maximumEnvelopeItemCount) {
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`,
statusCode: 400,
});
}
// For each file, stream to s3 and create the document data.
const envelopeItems = await Promise.all(
files.map(async (file) => {
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
return {
title: file.name,
documentDataId,
};
}),
);
const recipientsToCreate = recipients?.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
accessAuth: recipient.accessAuth,
actionAuth: recipient.actionAuth,
fields: recipient.fields?.map((field) => {
let documentDataId: string | undefined = undefined;
if (typeof field.identifier === 'string') {
documentDataId = envelopeItems.find(
(item) => item.title === field.identifier,
)?.documentDataId;
}
if (typeof field.identifier === 'number') {
documentDataId = envelopeItems.at(field.identifier)?.documentDataId;
}
if (field.identifier === undefined) {
documentDataId = envelopeItems[0]?.documentDataId;
}
if (!documentDataId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document data not found',
});
}
return {
...field,
documentDataId,
};
}),
}));
const envelope = await createEnvelope({
userId: user.id,
teamId,
@@ -115,9 +63,9 @@ export const createEnvelopeRoute = authenticatedProcedure
visibility,
globalAccessAuth,
globalActionAuth,
recipients: recipientsToCreate,
recipients,
folderId,
envelopeItems,
envelopeItems: items,
},
attachments,
meta,
@@ -1,6 +1,5 @@
import { EnvelopeType } from '@prisma/client';
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import {
ZDocumentAccessAuthTypesSchema,
@@ -18,28 +17,24 @@ import {
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zodFormData } from '../../utils/zod-form-data';
import {
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
} from '../document-router/schema';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
// Currently not in use until we allow passthrough documents on create.
export const createEnvelopeMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/envelope/create',
contentTypes: ['multipart/form-data'],
summary: 'Create envelope',
description: 'Create a envelope using form data.',
tags: ['Envelope'],
},
};
// export const createEnvelopeMeta: TrpcRouteMeta = {
// openapi: {
// method: 'POST',
// path: '/envelope/create',
// summary: 'Create envelope',
// tags: ['Envelope'],
// },
// };
export const ZCreateEnvelopePayloadSchema = z.object({
export const ZCreateEnvelopeRequestSchema = z.object({
title: ZDocumentTitleSchema,
type: z.nativeEnum(EnvelopeType),
externalId: ZDocumentExternalIdSchema.optional(),
@@ -47,6 +42,12 @@ export const ZCreateEnvelopePayloadSchema = z.object({
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
formValues: ZDocumentFormValuesSchema.optional(),
items: z
.object({
title: ZDocumentTitleSchema.optional(),
documentDataId: z.string(),
})
.array(),
folderId: z
.string()
.describe(
@@ -58,12 +59,11 @@ export const ZCreateEnvelopePayloadSchema = z.object({
ZCreateRecipientSchema.extend({
fields: ZFieldAndMetaSchema.and(
z.object({
identifier: z
.union([z.string(), z.number()])
documentDataId: z
.string()
.describe(
'Either the filename or the index of the file that was uploaded to attach the field to.',
)
.optional(),
'The ID of the document data to create the field on. If empty, the first document data will be used.',
),
page: ZFieldPageNumberSchema,
positionX: ZFieldPageXSchema,
positionY: ZFieldPageYSchema,
@@ -88,15 +88,9 @@ export const ZCreateEnvelopePayloadSchema = z.object({
.optional(),
});
export const ZCreateEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZCreateEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfd.file()),
});
export const ZCreateEnvelopeResponseSchema = z.object({
id: z.string(),
});
export type TCreateEnvelopePayload = z.infer<typeof ZCreateEnvelopePayloadSchema>;
export type TCreateEnvelopeRequest = z.infer<typeof ZCreateEnvelopeRequestSchema>;
export type TCreateEnvelopeResponse = z.infer<typeof ZCreateEnvelopeResponseSchema>;
+8 -8
View File
@@ -115,10 +115,10 @@ export const ZSetDocumentFieldsRequestSchema = z.object({
recipientId: z.number().min(1),
envelopeItemId: z.string(),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
pageX: z.number().min(0).max(100),
pageY: z.number().min(0).max(100),
pageWidth: z.number().min(0).max(100),
pageHeight: z.number().min(0).max(100),
fieldMeta: ZFieldMetaSchema,
}),
),
@@ -137,10 +137,10 @@ export const ZSetFieldsForTemplateRequestSchema = z.object({
recipientId: z.number().min(1),
envelopeItemId: z.string(),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
pageX: z.number().min(0).max(100),
pageY: z.number().min(0).max(100),
pageWidth: z.number().min(0).max(100),
pageHeight: z.number().min(0).max(100),
fieldMeta: ZFieldMetaSchema,
}),
),
+10 -19
View File
@@ -21,7 +21,6 @@ import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/de
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
@@ -160,27 +159,20 @@ export const templateRouter = router({
* @private
*/
createTemplate: authenticatedProcedure
.meta({
// Note before releasing this to public, update the response schema to be correct.
openapi: {
method: 'POST',
path: '/template/create',
contentTypes: ['multipart/form-data'],
summary: 'Create template',
description: 'Create a new template',
tags: ['Template'],
},
})
// .meta({ // Note before releasing this to public, update the response schema to be correct.
// openapi: {
// method: 'POST',
// path: '/template/create',
// summary: 'Create template',
// description: 'Create a new template',
// tags: ['Template'],
// },
// })
.input(ZCreateTemplateMutationSchema)
.output(ZCreateTemplateResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { payload, file } = input;
const { title, folderId } = payload;
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(file);
const { title, templateDocumentDataId, folderId } = input;
ctx.logger.info({
input: {
@@ -206,7 +198,6 @@ export const templateRouter = router({
});
return {
envelopeId: envelope.id,
legacyTemplateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
};
}),
+2 -10
View File
@@ -1,6 +1,5 @@
import { DocumentSigningOrder, DocumentVisibility, TemplateType } from '@prisma/client';
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZDocumentSchema } from '@documenso/lib/types/document';
import {
@@ -30,7 +29,6 @@ import {
} from '@documenso/lib/types/template';
import { LegacyTemplateDirectLinkSchema } from '@documenso/prisma/types/template-legacy-schema';
import { zodFormData } from '../../utils/zod-form-data';
import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema';
export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50;
@@ -79,16 +77,12 @@ export const ZTemplateMetaUpsertSchema = z.object({
allowDictateNextSigner: z.boolean().optional(),
});
export const ZCreateTemplatePayloadSchema = z.object({
export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1).trim(),
templateDocumentDataId: z.string().min(1),
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),
@@ -224,7 +218,6 @@ export const ZCreateTemplateV2ResponseSchema = z.object({
});
export const ZCreateTemplateResponseSchema = z.object({
envelopeId: z.string(),
legacyTemplateId: z.number(),
});
@@ -274,7 +267,6 @@ export const ZBulkSendTemplateMutationSchema = z.object({
sendImmediately: z.boolean(),
});
export type TCreateTemplatePayloadSchema = z.infer<typeof ZCreateTemplatePayloadSchema>;
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
+2 -2
View File
@@ -1,4 +1,5 @@
import { TRPCError, initTRPC } from '@trpc/server';
import SuperJSON from 'superjson';
import type { AnyZodObject } from 'zod';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
@@ -8,7 +9,6 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { alphaid } from '@documenso/lib/universal/id';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { dataTransformer } from '../utils/data-transformer';
import type { TrpcContext } from './context';
// Can't import type from trpc-to-openapi because it breaks build, not sure why.
@@ -35,7 +35,7 @@ const t = initTRPC
.meta<TrpcRouteMeta>()
.context<TrpcContext>()
.create({
transformer: dataTransformer,
transformer: SuperJSON,
errorFormatter(opts) {
const { shape, error } = opts;
-17
View File
@@ -1,17 +0,0 @@
import type { DataTransformer } from '@trpc/server';
import SuperJSON from 'superjson';
export const dataTransformer: DataTransformer = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serialize: (data: any) => {
if (data instanceof FormData) {
return data;
}
return SuperJSON.serialize(data);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
deserialize: (data: any) => {
return SuperJSON.deserialize(data);
},
};
@@ -1,202 +0,0 @@
import { TRPCError } from '@trpc/server';
import type { FetchHandlerOptions } from '@trpc/server/adapters/fetch';
import type { ServerResponse } from 'node:http';
import { type OpenApiRouter, createOpenApiNodeHttpHandler } from 'trpc-to-openapi';
const CONTENT_TYPE_JSON = 'application/json';
const CONTENT_TYPE_URLENCODED = 'application/x-www-form-urlencoded';
const CONTENT_TYPE_MULTIPART = 'multipart/form-data';
const getUrlEncodedBody = async (req: Request) => {
const params = new URLSearchParams(await req.text());
const data: Record<string, unknown> = {};
for (const key of params.keys()) {
data[key] = params.getAll(key);
}
return data;
};
const getMultipartBody = async (req: Request) => {
const formData = await req.formData();
const data: Record<string, unknown> = {};
for (const key of formData.keys()) {
const values = formData.getAll(key);
// Return array for multiple values, single value otherwise (matches URL-encoded behavior)
data[key] = values.length > 1 ? values : values[0];
}
return data;
};
/**
* Parses the request body based on its content type.
*
* Handles JSON, URL-encoded, and multipart/form-data requests.
* For multipart requests, converts FormData to a plain object (similar to URL-encoded)
* so it can be validated by tRPC schemas. The content-type header is rewritten
* later to prevent downstream parsing issues.
*/
const getRequestBody = async (req: Request) => {
try {
const contentType = req.headers.get('content-type') || '';
if (contentType.includes(CONTENT_TYPE_JSON)) {
return {
isValid: true,
// Use JSON.parse instead of req.json() because req.json() does not throw on invalid JSON
data: JSON.parse(await req.text()),
};
}
if (contentType.includes(CONTENT_TYPE_URLENCODED)) {
return {
isValid: true,
data: await getUrlEncodedBody(req),
};
}
// Handle multipart/form-data by parsing as FormData and converting to a plain object.
// This mirrors how URL-encoded data is structured, allowing tRPC to validate it normally.
// The content-type header is rewritten to application/json later via the request proxy
// because createOpenApiNodeHttpHandler aborts on any bodied request that isn't application/json.
if (contentType.includes(CONTENT_TYPE_MULTIPART)) {
return {
isValid: true,
data: await getMultipartBody(req),
};
}
return {
isValid: true,
data: req.body,
};
} catch (err) {
return {
isValid: false,
cause: err,
};
}
};
/**
* Creates a proxy around the original Request that intercepts property access
* to transform the request for compatibility with the Node HTTP handler.
*
* Key transformations:
* - Parses and provides the body as a plain object (handles multipart/form-data conversion)
* - Rewrites content-type header for multipart requests to application/json
* (required because createOpenApiNodeHttpHandler aborts on non-JSON bodied requests)
*/
const createRequestProxy = async (req: Request, url?: string) => {
const body = await getRequestBody(req);
const originalContentType = req.headers.get('content-type') || '';
const isMultipart = originalContentType.includes(CONTENT_TYPE_MULTIPART);
return new Proxy(req, {
get: (target, prop) => {
switch (prop) {
case 'url':
return url ?? target.url;
case 'body': {
if (!body.isValid) {
throw new TRPCError({
code: 'PARSE_ERROR',
message: 'Failed to parse request body',
cause: body.cause,
});
}
return body.data;
}
case 'headers': {
const headers = new Headers(target.headers);
// Rewrite content-type header for multipart requests to application/json.
// This is necessary because `createOpenApiNodeHttpHandler` aborts on any bodied
// request that isn't application/json. Since we've already parsed the multipart
// data into a plain object above, this is safe to do.
if (isMultipart) {
headers.set('content-type', CONTENT_TYPE_JSON);
}
return headers;
}
default:
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
return (target as unknown as Record<string | number | symbol, unknown>)[prop];
}
},
});
};
export type CreateOpenApiFetchHandlerOptions<TRouter extends OpenApiRouter> = Omit<
FetchHandlerOptions<TRouter>,
'batching'
> & {
req: Request;
endpoint: `/${string}`;
};
export const createOpenApiFetchHandler = async <TRouter extends OpenApiRouter>(
opts: CreateOpenApiFetchHandlerOptions<TRouter>,
): Promise<Response> => {
const resHeaders = new Headers();
const url = new URL(opts.req.url.replace(opts.endpoint, ''));
const req: Request = await createRequestProxy(opts.req, url.toString());
// @ts-expect-error Inherited from original fetch handler in `trpc-to-openapi`
const openApiHttpHandler = createOpenApiNodeHttpHandler(opts);
return new Promise<Response>((resolve) => {
let statusCode: number;
// Create a mock ServerResponse object that bridges Node HTTP APIs with Fetch API Response.
// This allows the Node HTTP handler to work with Fetch API Request objects.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const res = {
setHeader: (key: string, value: string | readonly string[]) => {
if (typeof value === 'string') {
resHeaders.set(key, value);
} else {
for (const v of value) {
resHeaders.append(key, v);
}
}
},
get statusCode() {
return statusCode;
},
set statusCode(code: number) {
statusCode = code;
},
end: (body: string) => {
resolve(
new Response(body, {
headers: resHeaders,
status: statusCode,
}),
);
},
} as ServerResponse;
// Type assertions are necessary here for interop between Fetch API Request/Response
// and Node HTTP IncomingMessage/ServerResponse types.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const nodeReq = req as unknown as Parameters<typeof openApiHttpHandler>[0];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const nodeRes = res as unknown as Parameters<typeof openApiHttpHandler>[1];
void openApiHttpHandler(nodeReq, nodeRes);
});
};
-32
View File
@@ -1,32 +0,0 @@
import type { ZodRawShape } from 'zod';
import z from 'zod';
/**
* This helper takes the place of the `z.object` at the root of your schema.
* It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData`
* and transforms it into a regular object.
* If the `FormData` contains multiple entries with the same field name,
* it will automatically turn that field into an array.
*
* This is used instead of `zfd.formData()` because it receives `undefined`
* somewhere in the pipeline of our openapi schema generation and throws
* an error. This provides the same functionality as `zfd.formData()` but
* can be considered somewhat safer.
*/
export const zodFormData = <T extends ZodRawShape>(schema: T) => {
return z.preprocess((data) => {
if (data instanceof FormData) {
const formData: Record<string, unknown> = {};
for (const key of data.keys()) {
const values = data.getAll(key);
formData[key] = values.length > 1 ? values : values[0];
}
return formData;
}
return data;
}, z.object(schema));
};
+1 -1
View File
@@ -78,6 +78,6 @@
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5",
"zod": "^3.25.76"
"zod": "3.24.1"
}
}