feat: add formdata endpoints for documents,envelopes,templates

Adds the missing endpoints for documents, envelopes and
templates supporting file uploads in a singular request.

Also updates frontend components that would use the prior
hidden endpoints.
This commit is contained in:
Lucas Smith
2025-11-03 15:07:15 +11:00
parent c85c0cf610
commit 87aa628dc8
16 changed files with 262 additions and 106 deletions

View File

@ -7,9 +7,9 @@ import { FilePlus, Loader } from 'lucide-react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session'; 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 { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -54,13 +54,17 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
setIsUploadingFile(true); setIsUploadingFile(true);
try { try {
const response = await putPdfFile(file); const payload = {
const { legacyTemplateId: id } = await createTemplate({
title: file.name, title: file.name,
templateDocumentDataId: response.id,
folderId: folderId, 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({ toast({
title: _(msg`Template document uploaded`), title: _(msg`Template document uploaded`),

View File

@ -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 { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; 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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; 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 { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -62,14 +62,18 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
try { try {
setIsLoading(true); setIsLoading(true);
const response = await putPdfFile(file); const payload = {
const { legacyDocumentId: id } = await createDocument({
title: file.name, title: file.name,
documentDataId: response.id, timezone: userTimezone,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
folderId: folderId ?? undefined, 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(); void refreshLimits();

View File

@ -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 { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; 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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; 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 { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { import {
@ -73,14 +73,18 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
try { try {
setIsLoading(true); setIsLoading(true);
const response = await putPdfFile(file); const payload = {
const { legacyDocumentId: id } = await createDocument({
title: file.name, title: file.name,
documentDataId: response.id,
timezone: userTimezone, timezone: userTimezone,
folderId: folderId ?? undefined, 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(); void refreshLimits();

View File

@ -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 { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; 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 { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; 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 { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { import {
@ -78,35 +78,24 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
try { try {
setIsLoading(true); setIsLoading(true);
const result = await Promise.all( const payload = {
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, folderId,
type, type,
title: files[0].name, title: files[0].name,
items: envelopeItemsToCreate,
meta: { meta: {
timezone: userTimezone, timezone: userTimezone,
}, },
}).catch((error) => { } 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) => {
console.error(error); console.error(error);
throw error; throw error;

View File

@ -10,9 +10,9 @@ import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; 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 { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -40,13 +40,17 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
try { try {
setIsLoading(true); setIsLoading(true);
const documentData = await putPdfFile(file); const payload = {
const { legacyTemplateId: id } = await createTemplate({
title: file.name, title: file.name,
templateDocumentDataId: documentData.id,
folderId: folderId ?? undefined, 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({ toast({
title: _(msg`Template uploaded`), title: _(msg`Template uploaded`),

View File

@ -16,11 +16,16 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { nanoid, prefixedId } from '@documenso/lib/universal/id'; import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; import type {
TDocumentAccessAuthTypes,
TDocumentActionAuthTypes,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
} from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values'; import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment'; import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta';
import { import {
ZWebhookDocumentSchema, ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload, mapEnvelopeToWebhookDocumentPayload,
@ -34,6 +39,25 @@ import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; 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 = { export type CreateEnvelopeOptions = {
userId: number; userId: number;
teamId: number; teamId: number;
@ -56,7 +80,7 @@ export type CreateEnvelopeOptions = {
visibility?: DocumentVisibility; visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[]; globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[]; globalActionAuth?: TDocumentActionAuthTypes[];
recipients?: TCreateEnvelopeRequest['recipients']; recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string; folderId?: string;
}; };
attachments?: Array<{ attachments?: Array<{

View File

@ -1,13 +1,22 @@
import { PDFDocument } from '@cantoo/pdf-lib'; import { PDFDocument } from '@cantoo/pdf-lib';
import { AppError } from '../../errors/app-error';
import { flattenAnnotations } from './flatten-annotations'; import { flattenAnnotations } from './flatten-annotations';
import { flattenForm, removeOptionalContentGroups } from './flatten-form'; import { flattenForm, removeOptionalContentGroups } from './flatten-form';
export const normalizePdf = async (pdf: Buffer) => { export const normalizePdf = async (pdf: Buffer) => {
const pdfDoc = await PDFDocument.load(pdf).catch(() => null); const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`);
if (!pdfDoc) { throw new AppError('INVALID_DOCUMENT_FILE', {
return pdf; message: 'The document is not a valid PDF',
});
});
if (pdfDoc.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'The document is encrypted',
});
} }
removeOptionalContentGroups(pdfDoc); removeOptionalContentGroups(pdfDoc);

View File

@ -7,6 +7,7 @@ import { env } from '@documenso/lib/utils/env';
import { AppError } from '../../errors/app-error'; import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data'; import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
import { uploadS3File } from './server-actions'; import { uploadS3File } from './server-actions';
type File = { type File = {
@ -43,6 +44,28 @@ export const putPdfFileServerSide = async (file: File) => {
return await createDocumentData({ type, data }); 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. * Uploads a file to the appropriate storage location.
*/ */

View File

@ -134,8 +134,8 @@ model Passkey {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) updatedAt DateTime @default(now())
lastUsedAt DateTime? lastUsedAt DateTime?
credentialId Bytes credentialId Bytes /// @zod.custom.use(z.instanceof(Uint8Array))
credentialPublicKey Bytes credentialPublicKey Bytes /// @zod.custom.use(z.instanceof(Uint8Array))
counter BigInt counter BigInt
credentialDeviceType String credentialDeviceType String
credentialBackedUp Boolean credentialBackedUp Boolean

View File

@ -3,6 +3,7 @@ import { EnvelopeType } from '@prisma/client';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope'; 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 { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { authenticatedProcedure } from '../trpc'; import { authenticatedProcedure } from '../trpc';
@ -16,7 +17,12 @@ export const createDocumentRoute = authenticatedProcedure
.output(ZCreateDocumentResponseSchema) .output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx; const { user, teamId } = ctx;
const { title, documentDataId, timezone, folderId, attachments } = input;
const { payload, file } = input;
const { title, timezone, folderId, attachments } = payload;
const { id: documentDataId } = await putNormalizedPdfFileServerSide(file);
ctx.logger.info({ ctx.logger.info({
input: { input: {
@ -55,6 +61,7 @@ export const createDocumentRoute = authenticatedProcedure
}); });
return { return {
envelopeId: document.id,
legacyDocumentId: mapSecondaryIdToDocumentId(document.secondaryId), legacyDocumentId: mapSecondaryIdToDocumentId(document.secondaryId),
}; };
}); });

View File

@ -1,23 +1,27 @@
import { z } from 'zod'; import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta'; import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment'; import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { zodFormData } from '../../utils/zod-form-data';
import type { TrpcRouteMeta } from '../trpc';
import { ZDocumentTitleSchema } from './schema'; import { ZDocumentTitleSchema } from './schema';
// Currently not in use until we allow passthrough documents on create. // Currently not in use until we allow passthrough documents on create.
// export const createDocumentMeta: TrpcRouteMeta = { export const createDocumentMeta: TrpcRouteMeta = {
// openapi: { openapi: {
// method: 'POST', method: 'POST',
// path: '/document/create', path: '/document/create',
// summary: 'Create document', contentTypes: ['multipart/form-data'],
// tags: ['Document'], summary: 'Create document',
// }, description: 'Create a document using form data.',
// }; tags: ['Document'],
},
};
export const ZCreateDocumentRequestSchema = z.object({ export const ZCreateDocumentPayloadSchema = z.object({
title: ZDocumentTitleSchema, title: ZDocumentTitleSchema,
documentDataId: z.string().min(1),
timezone: ZDocumentMetaTimezoneSchema.optional(), timezone: ZDocumentMetaTimezoneSchema.optional(),
folderId: z.string().describe('The ID of the folder to create the document in').optional(), folderId: z.string().describe('The ID of the folder to create the document in').optional(),
attachments: z attachments: z
@ -31,9 +35,16 @@ export const ZCreateDocumentRequestSchema = z.object({
.optional(), .optional(),
}); });
export const ZCreateDocumentRequestSchema = zodFormData({
payload: zfd.json(ZCreateDocumentPayloadSchema),
file: zfd.file(),
});
export const ZCreateDocumentResponseSchema = z.object({ export const ZCreateDocumentResponseSchema = z.object({
envelopeId: z.string(),
legacyDocumentId: z.number(), legacyDocumentId: z.number(),
}); });
export type TCreateDocumentPayloadSchema = z.infer<typeof ZCreateDocumentPayloadSchema>;
export type TCreateDocumentRequest = z.infer<typeof ZCreateDocumentRequestSchema>; export type TCreateDocumentRequest = z.infer<typeof ZCreateDocumentRequestSchema>;
export type TCreateDocumentResponse = z.infer<typeof ZCreateDocumentResponseSchema>; export type TCreateDocumentResponse = z.infer<typeof ZCreateDocumentResponseSchema>;

View File

@ -1,6 +1,7 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope'; 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 { authenticatedProcedure } from '../trpc';
import { import {
@ -13,6 +14,9 @@ export const createEnvelopeRoute = authenticatedProcedure
.output(ZCreateEnvelopeResponseSchema) .output(ZCreateEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx; const { user, teamId } = ctx;
const { payload, files } = input;
const { const {
title, title,
type, type,
@ -22,10 +26,9 @@ export const createEnvelopeRoute = authenticatedProcedure
globalActionAuth, globalActionAuth,
recipients, recipients,
folderId, folderId,
items,
meta, meta,
attachments, attachments,
} = input; } = payload;
ctx.logger.info({ ctx.logger.info({
input: { input: {
@ -45,13 +48,62 @@ export const createEnvelopeRoute = authenticatedProcedure
}); });
} }
if (items.length > maximumEnvelopeItemCount) { if (files.length > maximumEnvelopeItemCount) {
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', { throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`, message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`,
statusCode: 400, 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({ const envelope = await createEnvelope({
userId: user.id, userId: user.id,
teamId, teamId,
@ -63,9 +115,9 @@ export const createEnvelopeRoute = authenticatedProcedure
visibility, visibility,
globalAccessAuth, globalAccessAuth,
globalActionAuth, globalActionAuth,
recipients, recipients: recipientsToCreate,
folderId, folderId,
envelopeItems: items, envelopeItems,
}, },
attachments, attachments,
meta, meta,

View File

@ -1,5 +1,6 @@
import { EnvelopeType } from '@prisma/client'; import { EnvelopeType } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { import {
ZDocumentAccessAuthTypesSchema, ZDocumentAccessAuthTypesSchema,
@ -17,24 +18,28 @@ import {
} from '@documenso/lib/types/field'; } from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { zodFormData } from '../../utils/zod-form-data';
import { import {
ZDocumentExternalIdSchema, ZDocumentExternalIdSchema,
ZDocumentTitleSchema, ZDocumentTitleSchema,
ZDocumentVisibilitySchema, ZDocumentVisibilitySchema,
} from '../document-router/schema'; } from '../document-router/schema';
import { ZCreateRecipientSchema } from '../recipient-router/schema'; import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
// Currently not in use until we allow passthrough documents on create. // Currently not in use until we allow passthrough documents on create.
// export const createEnvelopeMeta: TrpcRouteMeta = { export const createEnvelopeMeta: TrpcRouteMeta = {
// openapi: { openapi: {
// method: 'POST', method: 'POST',
// path: '/envelope/create', path: '/envelope/create',
// summary: 'Create envelope', contentTypes: ['multipart/form-data'],
// tags: ['Envelope'], summary: 'Create envelope',
// }, description: 'Create a envelope using form data.',
// }; tags: ['Envelope'],
},
};
export const ZCreateEnvelopeRequestSchema = z.object({ export const ZCreateEnvelopePayloadSchema = z.object({
title: ZDocumentTitleSchema, title: ZDocumentTitleSchema,
type: z.nativeEnum(EnvelopeType), type: z.nativeEnum(EnvelopeType),
externalId: ZDocumentExternalIdSchema.optional(), externalId: ZDocumentExternalIdSchema.optional(),
@ -42,12 +47,6 @@ export const ZCreateEnvelopeRequestSchema = z.object({
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(), globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(), globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
formValues: ZDocumentFormValuesSchema.optional(), formValues: ZDocumentFormValuesSchema.optional(),
items: z
.object({
title: ZDocumentTitleSchema.optional(),
documentDataId: z.string(),
})
.array(),
folderId: z folderId: z
.string() .string()
.describe( .describe(
@ -59,11 +58,12 @@ export const ZCreateEnvelopeRequestSchema = z.object({
ZCreateRecipientSchema.extend({ ZCreateRecipientSchema.extend({
fields: ZFieldAndMetaSchema.and( fields: ZFieldAndMetaSchema.and(
z.object({ z.object({
documentDataId: z identifier: z
.string() .union([z.string(), z.number()])
.describe( .describe(
'The ID of the document data to create the field on. If empty, the first document data will be used.', 'Either the filename or the index of the file that was uploaded to attach the field to.',
), )
.optional(),
page: ZFieldPageNumberSchema, page: ZFieldPageNumberSchema,
positionX: ZFieldPageXSchema, positionX: ZFieldPageXSchema,
positionY: ZFieldPageYSchema, positionY: ZFieldPageYSchema,
@ -88,9 +88,15 @@ export const ZCreateEnvelopeRequestSchema = z.object({
.optional(), .optional(),
}); });
export const ZCreateEnvelopeRequestSchema = zodFormData({
payload: zfd.json(ZCreateEnvelopePayloadSchema),
files: zfd.repeatableOfType(zfd.file()),
});
export const ZCreateEnvelopeResponseSchema = z.object({ export const ZCreateEnvelopeResponseSchema = z.object({
id: z.string(), id: z.string(),
}); });
export type TCreateEnvelopePayload = z.infer<typeof ZCreateEnvelopePayloadSchema>;
export type TCreateEnvelopeRequest = z.infer<typeof ZCreateEnvelopeRequestSchema>; export type TCreateEnvelopeRequest = z.infer<typeof ZCreateEnvelopeRequestSchema>;
export type TCreateEnvelopeResponse = z.infer<typeof ZCreateEnvelopeResponseSchema>; export type TCreateEnvelopeResponse = z.infer<typeof ZCreateEnvelopeResponseSchema>;

View File

@ -21,6 +21,7 @@ import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/de
import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; 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 { 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 { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { mapFieldToLegacyField } from '@documenso/lib/utils/fields'; import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
@ -159,20 +160,27 @@ export const templateRouter = router({
* @private * @private
*/ */
createTemplate: authenticatedProcedure createTemplate: authenticatedProcedure
// .meta({ // Note before releasing this to public, update the response schema to be correct. .meta({
// openapi: { // Note before releasing this to public, update the response schema to be correct.
// method: 'POST', openapi: {
// path: '/template/create', method: 'POST',
// summary: 'Create template', path: '/template/create',
// description: 'Create a new template', contentTypes: ['multipart/form-data'],
// tags: ['Template'], summary: 'Create template',
// }, description: 'Create a new template',
// }) tags: ['Template'],
},
})
.input(ZCreateTemplateMutationSchema) .input(ZCreateTemplateMutationSchema)
.output(ZCreateTemplateResponseSchema) .output(ZCreateTemplateResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { teamId } = ctx; const { teamId } = ctx;
const { title, templateDocumentDataId, folderId } = input;
const { payload, file } = input;
const { title, folderId } = payload;
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(file);
ctx.logger.info({ ctx.logger.info({
input: { input: {
@ -198,6 +206,7 @@ export const templateRouter = router({
}); });
return { return {
envelopeId: envelope.id,
legacyTemplateId: mapSecondaryIdToTemplateId(envelope.secondaryId), legacyTemplateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
}; };
}), }),

View File

@ -1,5 +1,6 @@
import { DocumentSigningOrder, DocumentVisibility, TemplateType } from '@prisma/client'; import { DocumentSigningOrder, DocumentVisibility, TemplateType } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { zfd } from 'zod-form-data';
import { ZDocumentSchema } from '@documenso/lib/types/document'; import { ZDocumentSchema } from '@documenso/lib/types/document';
import { import {
@ -29,6 +30,7 @@ import {
} from '@documenso/lib/types/template'; } from '@documenso/lib/types/template';
import { LegacyTemplateDirectLinkSchema } from '@documenso/prisma/types/template-legacy-schema'; import { LegacyTemplateDirectLinkSchema } from '@documenso/prisma/types/template-legacy-schema';
import { zodFormData } from '../../utils/zod-form-data';
import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema'; import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema';
export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50; export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50;
@ -77,12 +79,16 @@ export const ZTemplateMetaUpsertSchema = z.object({
allowDictateNextSigner: z.boolean().optional(), allowDictateNextSigner: z.boolean().optional(),
}); });
export const ZCreateTemplateMutationSchema = z.object({ export const ZCreateTemplatePayloadSchema = z.object({
title: z.string().min(1).trim(), title: z.string().min(1).trim(),
templateDocumentDataId: z.string().min(1),
folderId: z.string().optional(), folderId: z.string().optional(),
}); });
export const ZCreateTemplateMutationSchema = zodFormData({
payload: zfd.json(ZCreateTemplatePayloadSchema),
file: zfd.file(),
});
export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({ export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({
directRecipientName: z.string().max(255).optional(), directRecipientName: z.string().max(255).optional(),
directRecipientEmail: z.string().email().max(254), directRecipientEmail: z.string().email().max(254),
@ -218,6 +224,7 @@ export const ZCreateTemplateV2ResponseSchema = z.object({
}); });
export const ZCreateTemplateResponseSchema = z.object({ export const ZCreateTemplateResponseSchema = z.object({
envelopeId: z.string(),
legacyTemplateId: z.number(), legacyTemplateId: z.number(),
}); });
@ -267,6 +274,7 @@ export const ZBulkSendTemplateMutationSchema = z.object({
sendImmediately: z.boolean(), sendImmediately: z.boolean(),
}); });
export type TCreateTemplatePayloadSchema = z.infer<typeof ZCreateTemplatePayloadSchema>;
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>; export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>; export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>; export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;

View File

@ -2,14 +2,16 @@ import type { DataTransformer } from '@trpc/server';
import SuperJSON from 'superjson'; import SuperJSON from 'superjson';
export const dataTransformer: DataTransformer = { export const dataTransformer: DataTransformer = {
serialize: (data: unknown) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
serialize: (data: any) => {
if (data instanceof FormData) { if (data instanceof FormData) {
return data; return data;
} }
return SuperJSON.serialize(data); return SuperJSON.serialize(data);
}, },
deserialize: (data: unknown) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
deserialize: (data: any) => {
return SuperJSON.deserialize(data); return SuperJSON.deserialize(data);
}, },
}; };