Compare commits

..

17 Commits

Author SHA1 Message Date
717fa8f870 fix: add endpoints for getting files 2025-11-04 15:18:11 +11:00
8663c8f883 fix: various envelope updates 2025-11-04 14:57:42 +11:00
c89ca83f44 fix: redirect v2 beta url 2025-11-04 11:55:07 +11:00
bbf1dd3c6b fix: add tests 2025-11-03 20:30:35 +11:00
c10c95ca00 fix: add tests 2025-11-03 20:17:52 +11:00
4a0425b120 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.
2025-11-03 15:11:20 +11:00
a6e923dd8a feat: allow multipart requests for public api
Adds support for multipart/form-data requests in the public api
allowing documents to be uploaded without having to perform a secondary
request.

Need to rollout further endpoints for envelopes and templates.

Need to change how we store files to not use `putFileServerSide`
2025-11-03 15:10:28 +11:00
7e38d06ef5 Merge branch 'main' into feat/add-envelopes-api 2025-11-01 12:47:55 +11:00
4e2443396c fix: increase res 2025-10-31 20:49:57 +11:00
2e2980f04f fix: increase res 2025-10-31 20:28:45 +11:00
3efe0de52f fix: increase threshold 2025-10-31 17:43:33 +11:00
efbd133f0e fix: increase threshold 2025-10-31 17:21:33 +11:00
4993e8a306 fix: test 2025-10-31 17:06:59 +11:00
f93d34c38e fix: clean up endpoints 2025-10-31 15:48:05 +11:00
8c228f965a fix: test 2025-10-31 15:06:20 +11:00
9020bbc753 fix: add regression test 2025-10-31 12:38:14 +11:00
f6bdb34b56 feat: add envelopes api 2025-10-28 20:32:24 +11:00
145 changed files with 6570 additions and 1244 deletions

View File

@ -6,7 +6,7 @@ import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/cl
import { DownloadIcon, FileTextIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -87,17 +87,11 @@ export const EnvelopeDownloadDialog = ({
}));
try {
const data = await getFile({
type: envelopeItem.documentData.type,
data:
version === 'signed'
? envelopeItem.documentData.data
: envelopeItem.documentData.initialData,
});
const downloadUrl = token
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${envelopeItemId}/download/${version}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
const blob = new Blob([data], {
type: 'application/pdf',
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const baseTitle = envelopeItem.title.replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';

View File

@ -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,13 +54,17 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
setIsUploadingFile(true);
try {
const response = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
const payload = {
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`),

View File

@ -1,14 +1,14 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -29,6 +29,8 @@ import {
} from '@documenso/ui/primitives/select';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useOptionalCurrentTeam } from '~/providers/team';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
@ -68,6 +70,9 @@ export function BrandingPreferencesForm({
}: BrandingPreferencesFormProps) {
const { t } = useLingui();
const team = useOptionalCurrentTeam();
const organisation = useCurrentOrganisation();
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
@ -88,14 +93,13 @@ export function BrandingPreferencesForm({
const file = JSON.parse(settings.brandingLogo);
if ('type' in file && 'data' in file) {
void getFile(file).then((binaryData) => {
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
const logoUrl =
context === 'Team'
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
setPreviewUrl(objectUrl);
setHasLoadedPreview(true);
});
return;
setPreviewUrl(logoUrl);
setHasLoadedPreview(true);
}
}

View File

@ -205,6 +205,7 @@ export const DocumentSigningPageViewV2 = () => {
<div className="flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
{currentEnvelopeItem ? (
<PDFViewerKonvaLazy
renderer="signing"
key={currentEnvelopeItem.id}
documentDataId={currentEnvelopeItem.documentDataId}
customPageRenderer={EnvelopeSignerPageRenderer}

View File

@ -174,7 +174,7 @@ const DocumentCertificateQrV2 = ({
<div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
</div>
</div>
);

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 { 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,14 +62,18 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
try {
setIsLoading(true);
const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
const payload = {
title: file.name,
documentDataId: response.id,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
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();

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 { 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,14 +73,18 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
try {
setIsLoading(true);
const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
const payload = {
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();

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 { 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,35 +78,24 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
try {
setIsLoading(true);
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({
const payload = {
folderId,
type,
title: files[0].name,
items: envelopeItemsToCreate,
meta: {
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);
throw error;

View File

@ -26,7 +26,7 @@ import { fieldButtonList } from './envelope-editor-fields-drag-drop';
export default function EnvelopeEditorFieldsPageRenderer() {
const { t, i18n } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const interactiveTransformer = useRef<Transformer | null>(null);
@ -113,7 +113,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
pageLayer.current?.batchDraw();
};
const renderFieldOnLayer = (field: TLocalField) => {
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
return;
}
@ -159,6 +159,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
fieldGroup.on('dragend', handleResizeOrMove);
};
const renderFieldOnLayer = (field: TLocalField) => {
try {
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/

View File

@ -137,7 +137,10 @@ export const EnvelopeEditorFieldsPage = () => {
)}
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
<PDFViewerKonvaLazy
renderer="editor"
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />

View File

@ -229,7 +229,6 @@ export const EnvelopeEditorSettingsDialog = ({
const emails = emailData?.data || [];
// Todo: Envelopes this doesn't make sense (look at previous)
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
const onFormSubmit = async (data: TAddSettingsFormSchema) => {
@ -242,7 +241,6 @@ export const EnvelopeEditorSettingsDialog = ({
try {
await updateEnvelope({
envelopeId: envelope.id,
envelopeType: envelope.type,
data: {
externalId: data.externalId || null,
visibility: data.visibility,

View File

@ -142,7 +142,7 @@ export const EnvelopeEditorUploadPage = () => {
const { createdEnvelopeItems } = await createEnvelopeItems({
envelopeId: envelope.id,
items: envelopeItemsToCreate,
data: envelopeItemsToCreate,
}).catch((error) => {
console.error(error);

View File

@ -12,7 +12,8 @@ import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
export default function EnvelopeGenericPageRenderer() {
const { i18n } = useLingui();
const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, fields, getRecipientColorKey, setRenderError } =
useCurrentEnvelopeRender();
const {
stage,
@ -37,7 +38,7 @@ export default function EnvelopeGenericPageRenderer() {
[fields, pageContext.pageNumber],
);
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
const unsafeRenderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
@ -66,6 +67,15 @@ export default function EnvelopeGenericPageRenderer() {
});
};
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
try {
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/

View File

@ -36,7 +36,7 @@ import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-
export default function EnvelopeSignerPageRenderer() {
const { t, i18n } = useLingui();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
@ -87,7 +87,7 @@ export default function EnvelopeSignerPageRenderer() {
);
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
@ -244,7 +244,7 @@ export default function EnvelopeSignerPageRenderer() {
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); // Todo: Envelopes - Handle errors
await signField(field.id, payload);
}
if (payload?.value) {
@ -363,6 +363,15 @@ export default function EnvelopeSignerPageRenderer() {
fieldGroup.on('pointerdown', handleFieldGroupClick);
};
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
try {
unsafeRenderFieldOnLayer(unparsedField);
} catch (err) {
console.error(err);
setRenderError(true);
}
};
const signField = async (
fieldId: number,
payload: TSignEnvelopeFieldValue,
@ -389,7 +398,7 @@ export default function EnvelopeSignerPageRenderer() {
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
renderFieldOnLayer(field);
}
currentPageLayer.batchDraw();
@ -405,7 +414,7 @@ export default function EnvelopeSignerPageRenderer() {
localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw();
@ -423,7 +432,7 @@ export default function EnvelopeSignerPageRenderer() {
pageLayer.current.destroyChildren();
localPageFields.forEach((field) => {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw();

View File

@ -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,13 +40,17 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
try {
setIsLoading(true);
const documentData = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
const payload = {
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`),

View File

@ -156,7 +156,10 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<PDFViewerKonvaLazy
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
</CardContent>
</Card>
</EnvelopeRenderProvider>

View File

@ -179,7 +179,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<PDFViewerKonvaLazy
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
</CardContent>
</Card>
</EnvelopeRenderProvider>

View File

@ -1,5 +1,6 @@
import { FieldType } from '@prisma/client';
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TFieldCheckbox } from '@documenso/lib/types/field';
import { parseCheckboxCustomText } from '@documenso/lib/utils/fields';
@ -44,6 +45,13 @@ export const handleCheckboxFieldClick = async (
let checkedValues: number[] | null = newValues.filter((v) => v.isChecked).map((v) => v.index);
if (checkedValues.length === 0) {
return {
type: FieldType.CHECKBOX,
value: [],
};
}
if (validationRule && validationLength) {
const checkboxValidationRule = checkboxValidationSigns.find(
(sign) => sign.label === validationRule,
@ -55,12 +63,33 @@ export const handleCheckboxFieldClick = async (
});
}
checkedValues = await SignFieldCheckboxDialog.call({
fieldMeta: field.fieldMeta,
validationRule: checkboxValidationRule.value,
// Custom logic to make it flow better.
// If "at most" OR "exactly" 1 value then just return the new selected value if exists.
if (
(checkboxValidationRule.value === '=' || checkboxValidationRule.value === '<=') &&
validationLength === 1
) {
return {
type: FieldType.CHECKBOX,
value: [clickedCheckboxIndex],
};
}
const isValid = validateCheckboxLength(
checkedValues.length,
checkboxValidationRule.value,
validationLength,
preselectedIndices: currentCheckedIndices,
});
);
// Only render validation dialog if validation is invalid.
if (!isValid) {
checkedValues = await SignFieldCheckboxDialog.call({
fieldMeta: field.fieldMeta,
validationRule: checkboxValidationRule.value,
validationLength,
preselectedIndices: checkedValues,
});
}
}
if (!checkedValues) {

View File

@ -0,0 +1,82 @@
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
import { type Context } from 'hono';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import type { HonoEnv } from '../router';
type HandleEnvelopeItemFileRequestOptions = {
title: string;
status: DocumentStatus;
documentData: {
type: DocumentDataType;
data: string;
initialData: string;
};
version: 'signed' | 'original';
isDownload: boolean;
context: Context<HonoEnv>;
};
/**
* Helper function to handle envelope item file requests (both view and download)
*/
export const handleEnvelopeItemFileRequest = async ({
title,
status,
documentData,
version,
isDownload,
context: c,
}: HandleEnvelopeItemFileRequestOptions) => {
const documentDataToUse = version === 'signed' ? documentData.data : documentData.initialData;
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
if (c.req.header('If-None-Match') === etag) {
return c.body(null, 304);
}
const file = await getFileServerSide({
type: documentData.type,
data: documentDataToUse,
}).catch((error) => {
console.error(error);
return null;
});
if (!file) {
return c.json({ error: 'File not found' }, 404);
}
c.header('Content-Type', 'application/pdf');
c.header('Content-Length', file.length.toString());
c.header('ETag', etag);
if (!isDownload) {
if (status === DocumentStatus.COMPLETED) {
c.header('Cache-Control', 'public, max-age=31536000, immutable');
} else {
// Set a tiny 1 minute cache, with must-revalidate to ensure the client always checks for updates.
c.header('Cache-Control', 'public, max-age=60, must-revalidate');
}
}
if (isDownload) {
// Generate filename following the pattern from envelope-download-dialog.tsx
const baseTitle = title.replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
c.header('Content-Disposition', `attachment; filename="${filename}"`);
// For downloads, prevent caching to ensure fresh data
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
c.header('Pragma', 'no-cache');
c.header('Expires', '0');
}
return c.body(file);
};

View File

@ -1,21 +1,22 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import {
getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../router';
import { handleEnvelopeItemFileRequest } from './files.helpers';
import {
type TGetPresignedGetUrlResponse,
type TGetPresignedPostUrlResponse,
ZGetPresignedGetUrlRequestSchema,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
@ -42,29 +43,7 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'File too large' }, 400);
}
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
if (pdf.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE');
}
// Todo: (RR7) Test this.
if (!file.name.endsWith('.pdf')) {
Object.defineProperty(file, 'name', {
writable: true,
value: `${file.name}.pdf`,
});
}
const { type, data } = await putFileServerSide(file);
const result = await createDocumentData({ type, data });
const result = await putNormalizedPdfFileServerSide(file);
return c.json(result);
} catch (error) {
@ -72,19 +51,6 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Upload failed' }, 500);
}
})
.post('/presigned-get-url', sValidator('json', ZGetPresignedGetUrlRequestSchema), async (c) => {
const { key } = await c.req.json();
try {
const { url } = await getPresignGetUrl(key || '');
return c.json({ url } satisfies TGetPresignedGetUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const { fileName, contentType } = c.req.valid('json');
@ -97,4 +63,222 @@ export const filesRoute = new Hono<HonoEnv>()
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
});
})
.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
async (c) => {
const { envelopeId, envelopeItemId } = c.req.valid('param');
const session = await getOptionalSession(c);
if (!session.user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
where: {
id: envelopeItemId,
},
include: {
documentData: true,
},
},
},
});
if (!envelope) {
return c.json({ error: 'Envelope not found' }, 404);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
const team = await getTeamById({
userId: session.user.id,
teamId: envelope.teamId,
}).catch((error) => {
console.error(error);
return null;
});
if (!team) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
title: envelopeItem.title,
status: envelope.status,
documentData: envelopeItem.documentData,
version: 'signed',
isDownload: false,
context: c,
});
},
)
.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/download/:version?',
sValidator('param', ZGetEnvelopeItemFileDownloadRequestParamsSchema),
async (c) => {
const { envelopeId, envelopeItemId, version } = c.req.valid('param');
const session = await getOptionalSession(c);
if (!session.user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
where: {
id: envelopeItemId,
},
include: {
documentData: true,
},
},
},
});
if (!envelope) {
return c.json({ error: 'Envelope not found' }, 404);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
const team = await getTeamById({
userId: session.user.id,
teamId: envelope.teamId,
}).catch((error) => {
console.error(error);
return null;
});
if (!team) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
title: envelopeItem.title,
status: envelope.status,
documentData: envelopeItem.documentData,
version,
isDownload: true,
context: c,
});
},
)
.get(
'/token/:token/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileTokenRequestParamsSchema),
async (c) => {
const { token, envelopeItemId } = c.req.valid('param');
const envelopeItem = await prisma.envelopeItem.findFirst({
where: {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
},
include: {
envelope: true,
documentData: true,
},
});
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
title: envelopeItem.title,
status: envelopeItem.envelope.status,
documentData: envelopeItem.documentData,
version: 'signed',
isDownload: false,
context: c,
});
},
)
.get(
'/token/:token/envelopeItem/:envelopeItemId/download/:version?',
sValidator('param', ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema),
async (c) => {
const { token, envelopeItemId, version } = c.req.valid('param');
const envelopeItem = await prisma.envelopeItem.findFirst({
where: {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
},
include: {
envelope: true,
documentData: true,
},
});
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
title: envelopeItem.title,
status: envelopeItem.envelope.status,
documentData: envelopeItem.documentData,
version,
isDownload: true,
context: c,
});
},
);

View File

@ -24,15 +24,43 @@ export const ZGetPresignedPostUrlResponseSchema = z.object({
url: z.string().min(1),
});
export const ZGetPresignedGetUrlRequestSchema = z.object({
key: z.string().min(1),
});
export const ZGetPresignedGetUrlResponseSchema = z.object({
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export type TGetPresignedGetUrlRequest = z.infer<typeof ZGetPresignedGetUrlRequestSchema>;
export type TGetPresignedGetUrlResponse = z.infer<typeof ZGetPresignedGetUrlResponseSchema>;
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
});
export type TGetEnvelopeItemFileRequestParams = z.infer<
typeof ZGetEnvelopeItemFileRequestParamsSchema
>;
export const ZGetEnvelopeItemFileTokenRequestParamsSchema = z.object({
token: z.string().min(1),
envelopeItemId: z.string().min(1),
});
export type TGetEnvelopeItemFileTokenRequestParams = z.infer<
typeof ZGetEnvelopeItemFileTokenRequestParamsSchema
>;
export const ZGetEnvelopeItemFileDownloadRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
version: z.enum(['signed', 'original']).default('signed'),
});
export type TGetEnvelopeItemFileDownloadRequestParams = z.infer<
typeof ZGetEnvelopeItemFileDownloadRequestParamsSchema
>;
export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
token: z.string().min(1),
envelopeItemId: z.string().min(1),
version: z.enum(['signed', 'original']).default('signed'),
});
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema
>;

View File

@ -8,7 +8,7 @@ import type { Logger } from 'pino';
import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { logger } from '@documenso/lib/utils/logger';
@ -89,9 +89,22 @@ app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler());
app.use('/api/trpc/*', reactRouterTrpcServer);
// Unstable API server routes. Order matters for these two.
app.get(`${API_V2_URL}/openapi.json`, (c) => c.json(openApiDocument));
app.use(`${API_V2_URL}/*`, cors());
app.use(`${API_V2_URL}/*`, async (c) =>
openApiTrpcServerHandler(c, {
isBeta: false,
}),
);
// Unstable API server routes. Order matters for these two.
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
app.use(`${API_V2_BETA_URL}/*`, cors());
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
app.use(`${API_V2_BETA_URL}/*`, async (c) =>
openApiTrpcServerHandler(c, {
isBeta: true,
}),
);
export default app;

View File

@ -1,15 +1,22 @@
import type { Context } from 'hono';
import { createOpenApiFetchHandler } from 'trpc-to-openapi';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { API_V2_BETA_URL, API_V2_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) => {
type OpenApiTrpcServerHandlerOptions = {
isBeta: boolean;
};
export const openApiTrpcServerHandler = async (
c: Context,
{ isBeta }: OpenApiTrpcServerHandlerOptions,
) => {
return createOpenApiFetchHandler<typeof appRouter>({
endpoint: API_V2_BETA_URL,
endpoint: isBeta ? API_V2_BETA_URL : API_V2_URL,
router: appRouter,
createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }),
req: c.req.raw,

Binary file not shown.

BIN
assets/field-meta.pdf Normal file

Binary file not shown.

858
package-lock.json generated

File diff suppressed because it is too large Load Diff

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.8.2",
"@prisma/client": "^6.18.0",
"dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0",
"eslint": "^8.40.0",
@ -54,11 +54,21 @@
"nodemailer": "^6.10.1",
"playwright": "1.52.0",
"prettier": "^3.3.3",
"prisma": "^6.8.2",
"prisma": "^6.18.0",
"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",
@ -76,12 +86,12 @@
"mupdf": "^1.0.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "3.24.1"
"zod": "^3.25.76"
},
"overrides": {
"zod": "3.24.1"
"zod": "^3.25.76"
},
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}

View File

@ -17,14 +17,14 @@
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@ts-rest/core": "^3.30.5",
"@ts-rest/open-api": "^3.33.0",
"@ts-rest/serverless": "^3.30.5",
"@ts-rest/core": "^3.52.0",
"@ts-rest/open-api": "^3.52.0",
"@ts-rest/serverless": "^3.52.0",
"@types/swagger-ui-react": "^5.18.0",
"luxon": "^3.4.0",
"superjson": "^1.13.1",
"superjson": "^2.2.5",
"swagger-ui-react": "^5.21.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "^3.25.76"
}
}

View File

@ -20,12 +20,12 @@ import {
getEnvelopeWhereInput,
} from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field';
import { updateDocumentFields } from '@documenso/lib/server-only/field/update-document-fields';
import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { deleteDocumentRecipient } from '@documenso/lib/server-only/recipient/delete-document-recipient';
import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/delete-envelope-recipient';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients';
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
@ -1285,7 +1285,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const updatedRecipient = await updateDocumentRecipients({
const updatedRecipient = await updateEnvelopeRecipients({
userId: user.id,
teamId: team.id,
id: {
@ -1336,7 +1336,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
},
});
const deletedRecipient = await deleteDocumentRecipient({
const deletedRecipient = await deleteEnvelopeRecipient({
userId: user.id,
teamId: team.id,
recipientId: Number(recipientId),
@ -1634,10 +1634,13 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const { fields } = await updateDocumentFields({
const { fields } = await updateEnvelopeFields({
userId: user.id,
teamId: team.id,
documentId: legacyDocumentId,
id: {
type: 'documentId',
id: legacyDocumentId,
},
fields: [
{
id: Number(fieldId),

View File

@ -0,0 +1,498 @@
import { FieldType } from '@prisma/client';
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
export type FieldTestData = TFieldAndMeta & {
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
customText: string;
signature?: string;
};
const columnWidth = 19.125;
const rowHeight = 6.7;
const alignmentGridStartX = 31;
const alignmentGridStartY = 19.02;
export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
/**
* Row 1 EMAIL
*/
{
type: FieldType.EMAIL,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'email',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'admin@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: {
textAlign: 'center',
type: 'email',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'admin@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'email',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'admin@documenso.com',
},
/**
* Row 2 NAME
*/
{
type: FieldType.NAME,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'name',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'John Doe',
},
{
type: FieldType.NAME,
fieldMeta: {
textAlign: 'center',
type: 'name',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'John Doe',
},
{
type: FieldType.NAME,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'name',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'John Doe',
},
/**
* Row 3 DATE
*/
{
type: FieldType.DATE,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'date',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.DATE,
fieldMeta: {
textAlign: 'center',
type: 'date',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.DATE,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'date',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
/**
* Row 4 TEXT
*/
{
type: FieldType.TEXT,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'text',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'center',
type: 'text',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'text',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
/**
* Row 5 NUMBER
*/
{
type: FieldType.NUMBER,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'number',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'center',
type: 'number',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'number',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
/**
* Row 6 Initials
*/
{
type: FieldType.INITIALS,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'initials',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'JD',
},
{
type: FieldType.INITIALS,
fieldMeta: {
textAlign: 'center',
type: 'initials',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'JD',
},
{
type: FieldType.INITIALS,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'initials',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'JD',
},
/**
* Row 7 Radio
*/
{
type: FieldType.RADIO,
fieldMeta: {
fontSize: 10,
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '0',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '2',
},
{
type: FieldType.RADIO,
fieldMeta: {
fontSize: 20,
direction: 'horizontal',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
},
/**
* Row 8 Checkbox
*/
{
type: FieldType.CHECKBOX,
fieldMeta: {
fontSize: 10,
direction: 'vertical',
type: 'checkbox',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: toCheckboxCustomText([0]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: toCheckboxCustomText([1]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
fontSize: 20,
direction: 'horizontal',
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
},
/**
* Row 8 Dropdown
*/
{
type: FieldType.DROPDOWN,
fieldMeta: {
fontSize: 10,
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
fontSize: 20,
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
type: 'dropdown',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'Option 1',
},
/**
* Row 9 Signature
*/
{
type: FieldType.SIGNATURE,
fieldMeta: {
fontSize: 10,
type: 'signature',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
fontSize: 20,
type: 'signature',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
signature: 'My Signature',
},
] as const;
export const formatAlignmentTestFields = ALIGNMENT_TEST_FIELDS.map((field, index) => {
const row = Math.floor(index / 3);
const column = index % 3;
return {
...field,
positionX: alignmentGridStartX + column * columnWidth,
positionY: alignmentGridStartY + row * rowHeight,
};
});

View File

@ -0,0 +1,482 @@
import { FieldType } from '@prisma/client';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import {
CheckboxValidationRules,
numberFormatValues,
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import type { FieldTestData } from './field-alignment-pdf';
const columnWidth = 20.1;
const fullColumnWidth = 75.8;
const rowHeight = 9.8;
const rowPadding = 1.8;
const alignmentGridStartX = 11.85;
const alignmentGridStartY = 15.07;
const calculatePosition = (row: number, column: number, width: 'full' | 'column' = 'column') => {
return {
height: rowHeight,
width: width === 'full' ? fullColumnWidth : columnWidth,
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
};
};
export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
/**
* PAGE 2 Signature
*/
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(0, 0),
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(1, 0),
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(2, 0),
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(3, 0),
customText: '',
signature: 'My Signature',
},
/**
* PAGE 3 TEXT
*/
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
},
page: 3,
...calculatePosition(0, 0, 'full'),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
},
page: 3,
...calculatePosition(1, 0),
customText: '123456789123456789123456789123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
characterLimit: 5,
},
page: 3,
...calculatePosition(2, 0),
customText: '12345',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
placeholder: 'Demo Placeholder',
},
page: 3,
...calculatePosition(3, 0),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
label: 'Demo Label',
},
page: 3,
...calculatePosition(3, 1),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
text: 'Prefilled text',
},
page: 3,
...calculatePosition(3, 2),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
required: true,
},
page: 3,
...calculatePosition(4, 0),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
readOnly: true,
text: 'Readonly Value',
},
page: 3,
...calculatePosition(4, 1),
customText: 'Readonly Value',
},
/**
* PAGE 4 NUMBER
*/
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
},
page: 4,
...calculatePosition(0, 0, 'full'),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
},
page: 4,
...calculatePosition(1, 0),
customText: '123456789123456789123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
minValue: 0,
maxValue: 100,
},
page: 4,
...calculatePosition(2, 0),
customText: '50',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
numberFormat: numberFormatValues[0].value, // Todo: Envelopes - Check this.
value: '123,456,789.00',
},
page: 4,
...calculatePosition(2, 1),
customText: '123,456,789.00',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
placeholder: 'Demo Placeholder',
},
page: 4,
...calculatePosition(3, 0),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
label: 'Demo Label',
},
page: 4,
...calculatePosition(3, 1),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
value: '123',
},
page: 4,
...calculatePosition(3, 2),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
required: true,
},
page: 4,
...calculatePosition(4, 0),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
readOnly: true,
},
page: 4,
...calculatePosition(4, 1),
customText: '123456789',
},
/**
* PAGE 5 RADIO
*/
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'horizontal',
type: 'radio',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(0, 0, 'full'),
customText: '0',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(1, 0),
customText: '2',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(2, 0),
customText: '',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(2, 1),
customText: '',
},
/**
* PAGE 6 CHECKBOX
*/
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'horizontal',
type: 'checkbox',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 2, checked: false, value: 'Option 3' },
{ id: 2, checked: false, value: 'Option 4' },
],
},
page: 6,
...calculatePosition(0, 0, 'full'),
customText: toCheckboxCustomText([0]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 2, checked: true, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(1, 0),
customText: toCheckboxCustomText([1]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
required: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 6,
...calculatePosition(2, 0),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
readOnly: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 6,
...calculatePosition(2, 1),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
validationRule: CheckboxValidationRules.SELECT_AT_LEAST,
validationLength: 2,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(3, 0),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
validationRule: CheckboxValidationRules.SELECT_EXACTLY,
validationLength: 2,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(3, 1),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
validationRule: CheckboxValidationRules.SELECT_AT_MOST,
validationLength: 2,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(3, 2),
customText: '',
},
/**
* PAGE 7 DROPDOWN
*/
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
},
page: 7,
...calculatePosition(0, 0, 'full'),
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
defaultValue: 'Option 1',
},
page: 7,
...calculatePosition(1, 0),
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
type: 'dropdown',
required: true,
},
page: 7,
...calculatePosition(2, 0),
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
type: 'dropdown',
readOnly: true,
},
page: 7,
...calculatePosition(2, 1),
customText: 'Option 1',
},
] as const;
export const formatFieldMetaTestFields = FIELD_META_TEST_FIELDS.map((field, index) => {
return {
...field,
};
});

View File

@ -0,0 +1,563 @@
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { pick } from 'remeda';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import {
DocumentDistributionMethod,
DocumentSigningOrder,
DocumentStatus,
DocumentVisibility,
EnvelopeType,
FieldType,
FolderType,
RecipientRole,
} from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TCreateEnvelopeItemsRequest } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import { formatAlignmentTestFields } from '../../../constants/field-alignment-pdf';
import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
test.describe('API V2 Envelopes', () => {
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
test.beforeEach(async () => {
({ user: userA, team: teamA } = await seedUser());
({ token: tokenA } = await createApiToken({
userId: userA.id,
teamId: teamA.id,
tokenName: 'userA',
expiresIn: null,
}));
({ user: userB, team: teamB } = await seedUser());
({ token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
}));
});
test.describe('Envelope create endpoint', () => {
test('should fail on invalid form', async ({ request }) => {
const payload = {
type: 'Invalid Type',
title: 'Test Envelope',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('should create envelope with single file', async ({ request }) => {
const payload = {
type: EnvelopeType.TEMPLATE,
title: 'Test Envelope',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUnique({
where: {
id: response.id,
},
include: {
envelopeItems: true,
},
});
expect(envelope).toBeDefined();
expect(envelope?.title).toBe('Test Envelope');
expect(envelope?.type).toBe(EnvelopeType.TEMPLATE);
expect(envelope?.status).toBe(DocumentStatus.DRAFT);
expect(envelope?.envelopeItems.length).toBe(1);
expect(envelope?.envelopeItems[0].title).toBe('field-font-alignment.pdf');
expect(envelope?.envelopeItems[0].documentDataId).toBeDefined();
});
test('should create envelope with multiple file', async ({ request }) => {
const folder = await prisma.folder.create({
data: {
name: 'Test Folder',
teamId: teamA.id,
userId: userA.id,
type: FolderType.DOCUMENT,
},
});
const payload = {
title: 'Envelope Title',
type: EnvelopeType.DOCUMENT,
externalId: 'externalId',
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'],
formValues: {
hello: 'world',
},
folderId: folder.id,
recipients: [
{
email: userA.email,
name: 'Name',
role: RecipientRole.SIGNER,
accessAuth: ['TWO_FACTOR_AUTH'],
signingOrder: 1,
fields: [
{
type: FieldType.SIGNATURE,
identifier: 'field-font-alignment.pdf',
page: 1,
positionX: 0,
positionY: 0,
width: 0,
height: 0,
},
{
type: FieldType.SIGNATURE,
identifier: 0,
page: 1,
positionX: 0,
positionY: 0,
width: 0,
height: 0,
},
],
},
],
meta: {
subject: 'Subject',
message: 'Message',
timezone: 'Europe/Berlin',
dateFormat: 'dd.MM.yyyy',
distributionMethod: DocumentDistributionMethod.NONE,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
allowDictateNextSigner: true,
redirectUrl: 'https://documenso.com',
language: 'de',
typedSignatureEnabled: true,
uploadSignatureEnabled: false,
drawSignatureEnabled: false,
emailReplyTo: userA.email,
emailSettings: {
recipientSigningRequest: false,
recipientRemoved: false,
recipientSigned: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: true,
},
},
attachments: [
{
label: 'Test Attachment',
data: 'https://documenso.com',
type: 'link',
},
],
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'field-meta.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/field-meta.pdf')),
},
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
// Should error since folder is not owned by the user.
const invalidRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(invalidRes.ok()).toBeFalsy();
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: response.id,
},
include: {
documentMeta: true,
envelopeItems: true,
recipients: true,
fields: true,
envelopeAttachments: true,
},
});
console.log(userB.email);
expect(envelope.envelopeItems.length).toBe(2);
expect(envelope.envelopeItems[0].title).toBe('field-meta.pdf');
expect(envelope.envelopeItems[1].title).toBe('field-font-alignment.pdf');
expect(envelope.title).toBe(payload.title);
expect(envelope.type).toBe(payload.type);
expect(envelope.externalId).toBe(payload.externalId);
expect(envelope.visibility).toBe(payload.visibility);
expect(envelope.authOptions).toEqual({
globalAccessAuth: payload.globalAccessAuth,
globalActionAuth: [],
});
expect(envelope.formValues).toEqual(payload.formValues);
expect(envelope.folderId).toBe(payload.folderId);
expect(envelope.documentMeta.subject).toBe(payload.meta.subject);
expect(envelope.documentMeta.message).toBe(payload.meta.message);
expect(envelope.documentMeta.timezone).toBe(payload.meta.timezone);
expect(envelope.documentMeta.dateFormat).toBe(payload.meta.dateFormat);
expect(envelope.documentMeta.distributionMethod).toBe(payload.meta.distributionMethod);
expect(envelope.documentMeta.signingOrder).toBe(payload.meta.signingOrder);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(
payload.meta.allowDictateNextSigner,
);
expect(envelope.documentMeta.redirectUrl).toBe(payload.meta.redirectUrl);
expect(envelope.documentMeta.language).toBe(payload.meta.language);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(
payload.meta.uploadSignatureEnabled,
);
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
expect([
{
label: envelope.envelopeAttachments[0].label,
data: envelope.envelopeAttachments[0].data,
type: envelope.envelopeAttachments[0].type,
},
]).toEqual(payload.attachments);
const field = envelope.fields[0];
const recipient = envelope.recipients[0];
expect({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
accessAuth: recipient.authOptions?.accessAuth,
}).toEqual(
pick(payload.recipients[0], ['email', 'name', 'role', 'signingOrder', 'accessAuth']),
);
expect({
type: field.type,
page: field.page,
positionX: field.positionX.toNumber(),
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
}).toEqual(
pick(payload.recipients[0].fields[0], [
'type',
'page',
'positionX',
'positionY',
'width',
'height',
]),
);
// Expect string based ID to work.
expect(field.envelopeItemId).toBe(
envelope.envelopeItems.find((item) => item.title === 'field-font-alignment.pdf')?.id,
);
// Expect index based ID to work.
expect(envelope.fields[1].envelopeItemId).toBe(
envelope.envelopeItems.find((item) => item.title === 'field-meta.pdf')?.id,
);
});
});
/**
* Creates envelopes with the two field test PDFs.
*/
test('Envelope full test', async ({ request }) => {
// Step 1: Create initial envelope with Prisma (with first envelope item)
const alignmentPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
);
const fieldMetaPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
);
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
title: 'Envelope Full Field Test',
} satisfies TCreateEnvelopePayload),
);
// Only add one file for now.
formData.append(
'files',
new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }),
);
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(createEnvelopeRequest.ok()).toBeTruthy();
expect(createEnvelopeRequest.status()).toBe(200);
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
const getEnvelopeRequest = await request.get(`${baseUrl}/envelope/${createdEnvelopeId}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const createdEnvelope: TGetEnvelopeResponse = await getEnvelopeRequest.json();
// Might as well testing access control here as well.
const unauthRequest = await request.get(`${baseUrl}/envelope/${createdEnvelopeId}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(unauthRequest.ok()).toBeFalsy();
expect(unauthRequest.status()).toBe(404);
// Step 2: Create second envelope item via API
// Todo: Envelopes - Use API Route
const fieldMetaDocumentData = await prisma.documentData.create({
data: {
type: 'BYTES_64',
data: fieldMetaPdf.toString('base64'),
initialData: fieldMetaPdf.toString('base64'),
},
});
const createEnvelopeItemsRequest: TCreateEnvelopeItemsRequest = {
envelopeId: createdEnvelope.id,
data: [
{
title: 'Field Meta Test',
documentDataId: fieldMetaDocumentData.id,
},
],
};
const createItemsRes = await request.post(`${baseUrl}/envelope/item/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createEnvelopeItemsRequest,
});
expect(createItemsRes.ok()).toBeTruthy();
expect(createItemsRes.status()).toBe(200);
// Step 3: Update envelope via API
const updateEnvelopeRequest: TUpdateEnvelopeRequest = {
envelopeId: createdEnvelope.id,
data: {
title: 'Envelope Full Field Test',
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
};
const updateRes = await request.post(`${baseUrl}/envelope/update`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: updateEnvelopeRequest,
});
expect(updateRes.ok()).toBeTruthy();
expect(updateRes.status()).toBe(200);
// Step 4: Create recipient via API
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createdEnvelope.id,
data: [
{
email: userA.email,
name: userA.name || '',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
expect(createRecipientsRes.status()).toBe(200);
// Step 5: Get envelope to retrieve recipients and envelope items
const getRes = await request.get(`${baseUrl}/envelope/${createdEnvelope.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(getRes.ok()).toBeTruthy();
expect(getRes.status()).toBe(200);
const envelopeResponse = (await getRes.json()) as TGetEnvelopeResponse;
const recipientId = envelopeResponse.recipients[0].id;
const alignmentItem = envelopeResponse.envelopeItems.find(
(item: { order: number }) => item.order === 1,
);
const fieldMetaItem = envelopeResponse.envelopeItems.find(
(item: { order: number }) => item.order === 2,
);
expect(recipientId).toBeDefined();
expect(alignmentItem).toBeDefined();
expect(fieldMetaItem).toBeDefined();
if (!alignmentItem || !fieldMetaItem) {
throw new Error('Envelope items not found');
}
// Step 6: Create fields for first PDF (alignment fields)
const alignmentFieldsRequest = {
envelopeId: createdEnvelope.id,
data: formatAlignmentTestFields.map((field) => ({
recipientId,
envelopeItemId: alignmentItem.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
})),
};
const createAlignmentFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: alignmentFieldsRequest,
});
expect(createAlignmentFieldsRes.ok()).toBeTruthy();
expect(createAlignmentFieldsRes.status()).toBe(200);
// Step 7: Create fields for second PDF (field-meta fields)
const fieldMetaFieldsRequest = {
envelopeId: createdEnvelope.id,
data: FIELD_META_TEST_FIELDS.map((field) => ({
recipientId,
envelopeItemId: fieldMetaItem.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
})),
};
const createFieldMetaFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: fieldMetaFieldsRequest,
});
expect(createFieldMetaFieldsRes.ok()).toBeTruthy();
expect(createFieldMetaFieldsRes.status()).toBe(200);
// Step 8: Verify final envelope structure
const finalGetRes = await request.get(`${baseUrl}/envelope/${createdEnvelope.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(finalGetRes.ok()).toBeTruthy();
const finalEnvelope = (await finalGetRes.json()) as TGetEnvelopeResponse;
// Verify structure
expect(finalEnvelope.envelopeItems.length).toBe(2);
expect(finalEnvelope.recipients.length).toBe(1);
expect(finalEnvelope.fields.length).toBe(
formatAlignmentTestFields.length + FIELD_META_TEST_FIELDS.length,
);
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
});
});

View File

@ -0,0 +1,282 @@
// sort-imports-ignore
// ---- PATCH pdfjs-dist's canvas require BEFORE importing it ----
import Module from 'module';
import { Canvas, Image } from 'skia-canvas';
// Intercept require('canvas') and return skia-canvas equivalents
const originalRequire = Module.prototype.require;
Module.prototype.require = function (path: string) {
if (path === 'canvas') {
return {
createCanvas: (width: number, height: number) => new Canvas(width, height),
Image, // needed by pdfjs-dist
};
}
// eslint-disable-next-line prefer-rest-params, @typescript-eslint/consistent-type-assertions
return originalRequire.apply(this, arguments as unknown as [string]);
};
import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs';
import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma';
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
test('field placement visual regression', async ({ page }, testInfo) => {
const { user, team } = await seedUser();
const envelope = await seedAlignmentTestDocument({
userId: user.id,
teamId: team.id,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: true,
status: DocumentStatus.PENDING,
});
const token = envelope.recipients[0].token;
const signUrl = `/sign/${token}`;
await apiSignin({
page,
email: user.email,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await expect(async () => {
const { status } = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
});
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass({
timeout: 10000,
});
const completedDocument = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
include: {
envelopeItems: {
orderBy: {
order: 'asc',
},
include: {
documentData: true,
},
},
},
});
const storedImages = fs.readdirSync(path.join(__dirname, '../../visual-regression'));
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const pdfData = await getFile(item.documentData);
const loadedImages = storedImages
.filter((image) => image.includes(item.title))
.map((image) => fs.readFileSync(path.join(__dirname, '../../visual-regression', image)));
await compareSignedPdfWithImages({
id: item.title.replaceAll(' ', '-').toLowerCase(),
pdfData,
images: loadedImages,
testInfo,
});
}),
);
});
/**
* Used to download the envelope images when updating the visual regression test.
*
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
*/
test.skip('download envelope images', async ({ page }) => {
const { user, team } = await seedUser();
const envelope = await seedAlignmentTestDocument({
userId: user.id,
teamId: team.id,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: true,
status: DocumentStatus.PENDING,
});
const token = envelope.recipients[0].token;
const signUrl = `/sign/${token}`;
await apiSignin({
page,
email: user.email,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await expect(async () => {
const { status } = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
});
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass({
timeout: 10000,
});
const completedDocument = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
include: {
envelopeItems: {
orderBy: {
order: 'asc',
},
include: {
documentData: true,
},
},
},
});
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const pdfData = await getFile(item.documentData);
const pdfImages = await renderPdfToImage(pdfData);
for (const [index, { image }] of pdfImages.entries()) {
fs.writeFileSync(
path.join(__dirname, '../../visual-regression', `${item.title}-${index}.png`),
new Uint8Array(image),
);
}
}),
);
});
async function renderPdfToImage(pdfBytes: Uint8Array) {
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
const pdf = await loadingTask.promise;
// Increase for higher resolution
const scale = 4;
return await Promise.all(
Array.from({ length: pdf.numPages }, async (_, index) => {
const page = await pdf.getPage(index + 1);
const viewport = page.getViewport({ scale });
const virtualCanvas = new Canvas(viewport.width, viewport.height);
const context = virtualCanvas.getContext('2d');
context.imageSmoothingEnabled = false;
// @ts-expect-error skia-canvas context satisfies runtime requirements for pdfjs
await page.render({ canvasContext: context, viewport }).promise;
return {
image: await virtualCanvas.toBuffer('png'),
// Rounded down because the certificate page somehow gives dimensions with decimals
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
};
}),
);
}
type CompareSignedPdfWithImagesOptions = {
id: string;
pdfData: Uint8Array;
images: Buffer[];
testInfo: TestInfo;
};
const compareSignedPdfWithImages = async ({
id,
pdfData,
images,
testInfo,
}: CompareSignedPdfWithImagesOptions) => {
const renderedImages = await renderPdfToImage(pdfData);
const blankCertificateFile = fs.readFileSync(
path.join(__dirname, '../../visual-regression/blank-certificate.png'),
);
const blankCertificateImage = PNG.sync.read(blankCertificateFile).data;
for (const [index, { image, width, height }] of renderedImages.entries()) {
const isCertificate = index === renderedImages.length - 1;
const diff = new PNG({ width, height });
const storedImage = PNG.sync.read(images[index]).data;
const newImage = PNG.sync.read(image).data;
const oldImage = isCertificate ? blankCertificateImage : storedImage;
const comparison = pixelMatch(
new Uint8Array(oldImage),
new Uint8Array(newImage),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
diff.data as unknown as Uint8Array,
width,
height,
{
threshold: 0.25,
// includeAA: true, // This allows stricter testing.
},
);
console.log(`${id}-${index}: ${comparison}`);
const diffFilePath = path.join(testInfo.outputPath(), `${id}-${index}-diff.png`);
const oldFilePath = path.join(testInfo.outputPath(), `${id}-${index}-old.png`);
const newFilePath = path.join(testInfo.outputPath(), `${id}-${index}-new.png`);
fs.writeFileSync(diffFilePath, new Uint8Array(PNG.sync.write(diff)));
fs.writeFileSync(oldFilePath, new Uint8Array(images[index]));
fs.writeFileSync(newFilePath, new Uint8Array(image));
if (isCertificate) {
// Expect the certificate to NOT be blank. Since the storedImage is blank.
expect.soft(comparison).toBeGreaterThan(20000);
} else {
expect.soft(comparison).toEqual(0);
}
}
};

View File

@ -15,7 +15,10 @@
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@playwright/test": "1.52.0",
"@types/node": "^20"
"@types/node": "^20",
"@types/pngjs": "^6.0.5",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0"
},
"dependencies": {
"start-server-and-test": "^2.0.12"

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -20,6 +20,6 @@
"luxon": "^3.5.0",
"nanoid": "^5.1.5",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "^3.25.76"
}
}

View File

@ -19,6 +19,6 @@
"micro": "^10.0.1",
"react": "^18",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "^3.25.76"
}
}

View File

@ -165,10 +165,7 @@ export const useEditorFields = ({
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, {
...localFields[index],
id,
});
form.setValue(`fields.${index}.id`, id);
}
};

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
@ -25,6 +25,8 @@ export function usePageRenderer(renderFunction: RenderFunction) {
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Konva.Layer | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
/**
* The raw viewport with no scaling. Basically the actual PDF size.
*/
@ -122,5 +124,7 @@ export function usePageRenderer(renderFunction: RenderFunction) {
unscaledViewport,
scaledViewport,
pageContext,
renderError,
setRenderError,
};
}

View File

@ -215,7 +215,6 @@ export const EnvelopeEditorProvider = ({
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
data: envelopeUpdates.data,
meta: envelopeUpdates.meta,
});

View File

@ -27,6 +27,9 @@ type EnvelopeRenderProviderValue = {
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: TEnvelope['fields'];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
renderError: boolean;
setRenderError: (renderError: boolean) => void;
};
interface EnvelopeRenderProviderProps {
@ -74,6 +77,8 @@ export const EnvelopeRenderProvider = ({
const [currentItem, setItem] = useState<EnvelopeRenderItem | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
const envelopeItems = useMemo(
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
[envelope.envelopeItems],
@ -164,6 +169,8 @@ export const EnvelopeRenderProvider = ({
setCurrentEnvelopeItem,
fields: fields ?? [],
getRecipientColorKey,
renderError,
setRenderError,
}}
>
{children}

View File

@ -12,6 +12,7 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL =
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
export const API_V2_BETA_URL = '/api/v2-beta';
export const API_V2_URL = '/api/v2';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';

View File

@ -55,11 +55,11 @@
"skia-canvas": "^3.0.8",
"stripe": "^12.7.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "^3.25.76"
},
"devDependencies": {
"@playwright/browser-chromium": "1.52.0",
"@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4"
}
}
}

View File

@ -20,7 +20,12 @@ import { validateCheckboxLength } from '../../advanced-fields-validation/validat
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldAndMetaSchema,
ZRadioFieldMeta,
} from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@ -174,9 +179,20 @@ export const sendDocument = async ({
const fieldsToAutoInsert: { fieldId: number; customText: string }[] = [];
// Auto insert radio and checkboxes that have default values.
// Validate and autoinsert fields for V2 envelopes.
if (envelope.internalVersion === 2) {
for (const field of envelope.fields) {
for (const unknownField of envelope.fields) {
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
if (parsedField.error) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message,
});
}
const field = parsedField.data;
const fieldId = unknownField.id;
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
@ -184,7 +200,7 @@ export const sendDocument = async ({
if (checkedItemIndex !== -1) {
fieldsToAutoInsert.push({
fieldId: field.id,
fieldId,
customText: toRadioCustomText(checkedItemIndex),
});
}
@ -195,7 +211,7 @@ export const sendDocument = async ({
if (defaultValue && values.some((value) => value.value === defaultValue)) {
fieldsToAutoInsert.push({
fieldId: field.id,
fieldId,
customText: defaultValue,
});
}
@ -234,9 +250,9 @@ export const sendDocument = async ({
);
}
if (isValid) {
if (isValid && checkedIndices.length > 0) {
fieldsToAutoInsert.push({
fieldId: field.id,
fieldId,
customText: toCheckboxCustomText(checkedIndices),
});
}

View File

@ -16,11 +16,16 @@ 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 } from '../../types/document-auth';
import type {
TDocumentAccessAuthTypes,
TDocumentActionAuthTypes,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
} 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,
@ -34,6 +39,25 @@ 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;
@ -46,7 +70,6 @@ export type CreateEnvelopeOptions = {
envelopeItems: { title?: string; documentDataId: string; order?: number }[];
formValues?: TDocumentFormValues;
timezone?: string;
userTimezone?: string;
templateType?: TemplateType;
@ -56,7 +79,7 @@ export type CreateEnvelopeOptions = {
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
recipients?: TCreateEnvelopeRequest['recipients'];
recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string;
};
attachments?: Array<{
@ -83,7 +106,6 @@ export const createEnvelope = async ({
title,
externalId,
formValues,
timezone,
userTimezone,
folderId,
templateType,
@ -142,6 +164,7 @@ export const createEnvelope = async ({
let envelopeItems: { title?: string; documentDataId: string; order?: number }[] =
data.envelopeItems;
// Todo: Envelopes - Remove
if (normalizePdf) {
envelopeItems = await Promise.all(
data.envelopeItems.map(async (item) => {
@ -219,7 +242,7 @@ export const createEnvelope = async ({
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone;
const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {

View File

@ -26,9 +26,9 @@ export interface CreateEnvelopeFieldsOptions {
envelopeItemId?: string;
recipientId: number;
pageNumber: number;
pageX: number;
pageY: number;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
})[];
@ -122,9 +122,9 @@ export const createEnvelopeFields = async ({
const newlyCreatedFields = await tx.field.createManyAndReturn({
data: validatedFields.map((field) => ({
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',

View File

@ -11,7 +11,7 @@ export type GetFieldByIdOptions = {
userId: number;
teamId: number;
fieldId: number;
envelopeType: EnvelopeType;
envelopeType?: EnvelopeType;
};
export const getFieldById = async ({
@ -41,7 +41,7 @@ export const getFieldById = async ({
type: 'envelopeId',
id: field.envelopeId,
},
type: envelopeType,
type: envelopeType ?? null,
userId,
teamId,
});

View File

@ -158,7 +158,7 @@ export const setFieldsForDocument = async ({
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
String(numberFieldParsedMeta.value || ''),
numberFieldParsedMeta,
false,
);

View File

@ -10,18 +10,21 @@ import {
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateDocumentFieldsOptions {
export interface UpdateEnvelopeFieldsOptions {
userId: number;
teamId: number;
documentId: number;
id: EnvelopeIdOptions;
type?: EnvelopeType | null; // Only used to enforce the type.
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
envelopeItemId?: string;
pageX?: number;
pageY?: number;
width?: number;
@ -31,19 +34,17 @@ export interface UpdateDocumentFieldsOptions {
requestMetadata: ApiRequestMetadata;
}
export const updateDocumentFields = async ({
export const updateEnvelopeFields = async ({
userId,
teamId,
documentId,
id,
type = null,
fields,
requestMetadata,
}: UpdateDocumentFieldsOptions) => {
}: UpdateEnvelopeFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
id,
type,
userId,
teamId,
});
@ -53,18 +54,19 @@ export const updateDocumentFields = async ({
include: {
recipients: true,
fields: true,
envelopeItems: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -96,6 +98,29 @@ export const updateDocumentFields = async ({
});
}
const fieldType = field.type || originalField.type;
const fieldMetaType = field.fieldMeta?.type || originalField.fieldMeta?.type;
// Not going to mess with V1 envelopes.
if (
envelope.internalVersion === 2 &&
fieldMetaType &&
fieldMetaType.toLowerCase() !== fieldType.toLowerCase()
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Field meta type does not match the field type',
});
}
if (
field.envelopeItemId &&
!envelope.envelopeItems.some((item) => item.id === field.envelopeItemId)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item not found',
});
}
return {
originalField,
updateData: field,
@ -118,27 +143,30 @@ export const updateDocumentFields = async ({
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
envelopeItemId: updateData.envelopeItemId,
},
});
const changes = diffFieldChanges(originalField, updatedField);
// Handle field updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: recipientEmail,
fieldRecipientId: updatedField.recipientId,
fieldType: updatedField.type,
changes,
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
const changes = diffFieldChanges(originalField, updatedField);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: recipientEmail,
fieldRecipientId: updatedField.recipientId,
fieldType: updatedField.type,
changes,
},
}),
});
}
}
return updatedField;

View File

@ -1,116 +0,0 @@
import { EnvelopeType, type FieldType } from '@prisma/client';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateTemplateFieldsOptions {
userId: number;
teamId: number;
templateId: number;
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
pageX?: number;
pageY?: number;
width?: number;
height?: number;
fieldMeta?: TFieldMetaSchema;
}[];
}
export const updateTemplateFields = async ({
userId,
teamId,
templateId,
fields,
}: UpdateTemplateFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
fields: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const fieldsToUpdate = fields.map((field) => {
const originalField = envelope.fields.find((existingField) => existingField.id === field.id);
if (!originalField) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Field with id ${field.id} not found`,
});
}
const recipient = envelope.recipients.find(
(recipient) => recipient.id === originalField.recipientId,
);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient attached to field ${field.id} not found`,
});
}
// Check whether the recipient associated with the field can be modified.
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
});
}
return {
updateData: field,
};
});
const updatedFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
fieldsToUpdate.map(async ({ updateData }) => {
const updatedField = await tx.field.update({
where: {
id: updateData.id,
},
data: {
type: updateData.type,
page: updateData.pageNumber,
positionX: updateData.pageX,
positionY: updateData.pageY,
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
},
});
return updatedField;
}),
);
});
return {
fields: updatedFields.map((field) => mapFieldToLegacyField(field, envelope)),
};
};

View File

@ -1,13 +1,22 @@
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(() => null);
const pdfDoc = await PDFDocument.load(pdf).catch((e) => {
console.error(`PDF normalization error: ${e.message}`);
if (!pdfDoc) {
return pdf;
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',
});
}
removeOptionalContentGroups(pdfDoc);

View File

@ -15,7 +15,7 @@ import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface CreateDocumentRecipientsOptions {
export interface CreateEnvelopeRecipientsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
@ -30,16 +30,16 @@ export interface CreateDocumentRecipientsOptions {
requestMetadata: ApiRequestMetadata;
}
export const createDocumentRecipients = async ({
export const createEnvelopeRecipients = async ({
userId,
teamId,
id,
recipients: recipientsToCreate,
requestMetadata,
}: CreateDocumentRecipientsOptions) => {
}: CreateEnvelopeRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
type: null,
userId,
teamId,
});
@ -62,13 +62,13 @@ export const createDocumentRecipients = async ({
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -112,21 +112,23 @@ export const createDocumentRecipients = async ({
});
// Handle recipient created audit log.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: createdRecipient.email,
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: createdRecipient.email,
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});
}
return createdRecipient;
}),

View File

@ -1,115 +0,0 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface CreateTemplateRecipientsOptions {
userId: number;
teamId: number;
templateId: number;
recipients: {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
export const createTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients: recipientsToCreate,
}: CreateTemplateRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const template = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
}));
const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const createdRecipient = await tx.recipient.create({
data: {
envelopeId: template.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
});
return createdRecipient;
}),
);
});
return {
recipients: createdRecipients.map((recipient) =>
mapRecipientToLegacyRecipient(recipient, template),
),
};
};

View File

@ -14,26 +14,27 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { canRecipientBeModified } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteDocumentRecipientOptions {
export interface DeleteEnvelopeRecipientOptions {
userId: number;
teamId: number;
recipientId: number;
requestMetadata: ApiRequestMetadata;
}
export const deleteDocumentRecipient = async ({
export const deleteEnvelopeRecipient = async ({
userId,
teamId,
recipientId,
requestMetadata,
}: DeleteDocumentRecipientOptions) => {
}: DeleteEnvelopeRecipientOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
id: recipientId,
@ -48,6 +49,9 @@ export const deleteDocumentRecipient = async ({
where: {
id: recipientId,
},
include: {
fields: true,
},
},
},
});
@ -89,24 +93,43 @@ export const deleteDocumentRecipient = async ({
});
}
const deletedRecipient = await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
recipientId: recipientToDelete.id,
recipientRole: recipientToDelete.role,
},
}),
if (!canRecipientBeModified(recipientToDelete, recipientToDelete.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient has already interacted with the document.',
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelope.id,
},
type: null,
userId,
teamId,
});
const deletedRecipient = await prisma.$transaction(async (tx) => {
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
recipientId: recipientToDelete.id,
recipientRole: recipientToDelete.role,
},
}),
});
}
return await tx.recipient.delete({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
});
});
@ -116,7 +139,11 @@ export const deleteDocumentRecipient = async ({
).recipientRemoved;
// Send email to deleted recipient.
if (recipientToDelete.sendStatus === SendStatus.SENT && isRecipientRemovedEmailEnabled) {
if (
recipientToDelete.sendStatus === SendStatus.SENT &&
isRecipientRemovedEmailEnabled &&
envelope.type === EnvelopeType.DOCUMENT
) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {

View File

@ -1,58 +0,0 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteTemplateRecipientOptions {
userId: number;
teamId: number;
recipientId: number;
}
export const deleteTemplateRecipient = async ({
userId,
teamId,
recipientId,
}: DeleteTemplateRecipientOptions): Promise<void> => {
const recipientToDelete = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelope: {
type: EnvelopeType.TEMPLATE,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
if (!recipientToDelete) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: recipientToDelete.envelopeId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
if (!recipientToDelete || recipientToDelete.id !== recipientId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
await prisma.recipient.delete({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
});
};

View File

@ -1,5 +1,4 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@ -16,29 +15,38 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { extractLegacyIds } from '../../universal/id';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateDocumentRecipientsOptions {
export interface UpdateEnvelopeRecipientsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
recipients: RecipientData[];
recipients: {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
requestMetadata: ApiRequestMetadata;
}
export const updateDocumentRecipients = async ({
export const updateEnvelopeRecipients = async ({
userId,
teamId,
id,
recipients,
requestMetadata,
}: UpdateDocumentRecipientsOptions) => {
}: UpdateEnvelopeRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
type: null,
userId,
teamId,
});
@ -62,13 +70,13 @@ export const updateDocumentRecipients = async ({
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -160,24 +168,26 @@ export const updateDocumentRecipients = async ({
});
}
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
// Handle recipient updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientId: updatedRecipient.id,
recipientRole: updatedRecipient.role,
changes,
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientId: updatedRecipient.id,
recipientRole: updatedRecipient.role,
changes,
},
}),
});
}
}
return updatedRecipient;
@ -188,19 +198,8 @@ export const updateDocumentRecipients = async ({
return {
recipients: updatedRecipients.map((recipient) => ({
...recipient,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
templateId: null,
...extractLegacyIds(envelope),
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
})),
};
};
type RecipientData = {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};

View File

@ -1,168 +0,0 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateTemplateRecipientsOptions {
userId: number;
teamId: number;
templateId: number;
recipients: {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
export const updateTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients,
}: UpdateTemplateRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth && !envelope.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = envelope.recipients.find(
(existingRecipient) => existingRecipient.id === recipient.id,
);
if (!originalRecipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient with id ${recipient.id} not found`,
});
}
return {
originalRecipient,
recipientUpdateData: recipient,
};
});
const updatedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
recipientsToUpdate.map(async ({ originalRecipient, recipientUpdateData }) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
if (
recipientUpdateData.actionAuth !== undefined ||
recipientUpdateData.accessAuth !== undefined
) {
authOptions = createRecipientAuthOptions({
accessAuth: recipientUpdateData.accessAuth || authOptions.accessAuth,
actionAuth: recipientUpdateData.actionAuth || authOptions.actionAuth,
});
}
const mergedRecipient = {
...originalRecipient,
...recipientUpdateData,
};
const updatedRecipient = await tx.recipient.update({
where: {
id: originalRecipient.id,
envelopeId: envelope.id,
},
data: {
name: mergedRecipient.name,
email: mergedRecipient.email,
role: mergedRecipient.role,
signingOrder: mergedRecipient.signingOrder,
envelopeId: envelope.id,
sendStatus:
mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
mergedRecipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
authOptions,
},
include: {
fields: true,
},
});
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
originalRecipient.role !== updatedRecipient.role &&
(updatedRecipient.role === RecipientRole.CC ||
updatedRecipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId: updatedRecipient.id,
},
});
}
return updatedRecipient;
}),
);
});
return {
recipients: updatedRecipients.map((recipient) => ({
...recipient,
documentId: null,
templateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
})),
};
};

View File

@ -37,11 +37,8 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
userId: true,
teamId: true,
folderId: true,
templateId: true,
}).extend({
templateId: z
.number()
.nullish()
.describe('The ID of the template that the document was created from, if any.'),
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,

View File

@ -188,7 +188,7 @@ export type TFieldMetaSchema = z.infer<typeof ZFieldMetaSchema>;
export const ZFieldAndMetaSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal(FieldType.SIGNATURE),
fieldMeta: z.undefined(),
fieldMeta: ZSignatureFieldMeta.optional(),
}),
z.object({
type: z.literal(FieldType.FREE_SIGNATURE),

View File

@ -50,6 +50,11 @@ export const ZFieldSchema = FieldSchema.pick({
templateId: z.number().nullish(),
});
export const ZEnvelopeFieldSchema = ZFieldSchema.omit({
documentId: true,
templateId: true,
});
export const ZFieldPageNumberSchema = z
.number()
.min(1)
@ -69,6 +74,30 @@ export const ZFieldWidthSchema = z.number().min(1).describe('The width of the fi
export const ZFieldHeightSchema = z.number().min(1).describe('The height of the field.');
export const ZClampedFieldPositionXSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based X coordinate where the field will be placed.');
export const ZClampedFieldPositionYSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based Y coordinate where the field will be placed.');
export const ZClampedFieldWidthSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based width of the field on the page.');
export const ZClampedFieldHeightSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based height of the field on the page.');
// ---------------------------------------------
const PrismaDecimalSchema = z.preprocess(

View File

@ -95,3 +95,18 @@ export const ZRecipientManySchema = RecipientSchema.pick({
documentId: z.number().nullish(),
templateId: z.number().nullish(),
});
export const ZEnvelopeRecipientSchema = ZRecipientSchema.omit({
documentId: true,
templateId: true,
});
export const ZEnvelopeRecipientLiteSchema = ZRecipientLiteSchema.omit({
documentId: true,
templateId: true,
});
export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
documentId: true,
templateId: true,
});

View File

@ -30,3 +30,5 @@ export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
return chacha.decrypt(dataAsBytes);
};
export { sha256 };

View File

@ -153,6 +153,11 @@ export const createFieldHoverInteraction = ({
const hoverColor = RECIPIENT_COLOR_STYLES[options.color].baseRingHover;
fieldGroup.on('mouseover', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -161,6 +166,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('mouseout', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -169,6 +179,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('transformstart', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
@ -177,6 +192,11 @@ export const createFieldHoverInteraction = ({
});
fieldGroup.on('transformend', () => {
const layer = fieldRect.getLayer();
if (!layer) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,

View File

@ -3,6 +3,7 @@ import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta';
import { parseCheckboxCustomText } from '../../utils/fields';
import {
createFieldHoverInteraction,
konvaTextFill,
@ -129,7 +130,7 @@ export const renderCheckboxFieldElement = (
pageLayer.batchDraw();
});
const checkedValues: number[] = field.customText ? JSON.parse(field.customText) : [];
const checkedValues: number[] = field.customText ? parseCheckboxCustomText(field.customText) : [];
checkboxValues.forEach(({ value, checked }, index) => {
const isCheckboxChecked = match(mode)
@ -169,7 +170,7 @@ export const renderCheckboxFieldElement = (
width: itemSize,
height: itemSize,
stroke: '#374151',
strokeWidth: 2,
strokeWidth: 1.5,
cornerRadius: 2,
fill: 'white',
});

View File

@ -159,7 +159,7 @@ export const renderRadioFieldElement = (
y: itemInputY,
radius: calculateRadioSize(fontSize) / 2,
stroke: '#374151',
strokeWidth: 2,
strokeWidth: 1.5,
fill: 'white',
});

View File

@ -7,7 +7,13 @@ export type GetFileOptions = {
data: string;
};
export const getFile = async ({ type, data }: GetFileOptions) => {
/**
* KEPT FOR POSTERITY, SHOULD BE REMOVED IN THE FUTURE
* DO NOT USE OR I WILL FIRE YOU
*
* - Lucas, 2025-11-04
*/
const getFile = async ({ type, data }: GetFileOptions) => {
return await match(type)
.with(DocumentDataType.BYTES, () => getFileFromBytes(data))
.with(DocumentDataType.BYTES_64, () => getFileFromBytes64(data))

View File

@ -7,6 +7,7 @@ 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 = {
@ -43,6 +44,28 @@ 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.
*/

View File

@ -104,7 +104,6 @@ export const extractFieldInsertionValues = ({
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(fieldValue.value.toString(), numberFieldParsedMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid number',
@ -127,7 +126,6 @@ export const extractFieldInsertionValues = ({
const parsedTextFieldMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(fieldValue.value, parsedTextFieldMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid email',
@ -189,7 +187,6 @@ export const extractFieldInsertionValues = ({
(sign) => sign.label === validationRule,
);
// Todo: Envelopes - Test this.
if (checkboxValidationRule) {
const isValid = validateCheckboxLength(
selectedValues.length,
@ -224,7 +221,6 @@ export const extractFieldInsertionValues = ({
const parsedDropdownFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(fieldValue.value, parsedDropdownFieldMeta, true);
// Todo: Envelopes
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid dropdown value',

View File

@ -81,6 +81,10 @@ export const mapFieldToLegacyField = (
};
export const parseCheckboxCustomText = (customText: string): number[] => {
if (!customText) {
return [];
}
return JSON.parse(customText);
};

View File

@ -21,14 +21,14 @@
"seed": "tsx ./seed-database.ts"
},
"dependencies": {
"@prisma/client": "^6.8.2",
"@prisma/client": "^6.18.0",
"kysely": "0.26.3",
"prisma": "^6.8.2",
"prisma": "^6.18.0",
"prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0",
"prisma-json-types-generator": "^3.2.2",
"prisma-json-types-generator": "^3.6.2",
"ts-pattern": "^5.0.6",
"zod-prisma-types": "3.2.4"
"zod-prisma-types": "3.3.5"
},
"devDependencies": {
"dotenv": "^16.5.0",

View File

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

View File

@ -1,11 +1,22 @@
import fs from 'node:fs';
import path from 'node:path';
import { formatAlignmentTestFields } from '@documenso/app-tests/constants/field-alignment-pdf';
import { FIELD_META_TEST_FIELDS } from '@documenso/app-tests/constants/field-meta-pdf';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { incrementDocumentId } from '@documenso/lib/server-only/envelope/increment-id';
import { prefixedId } from '@documenso/lib/universal/id';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '..';
import { DocumentDataType, DocumentSource, EnvelopeType } from '../client';
import {
DocumentDataType,
DocumentSource,
DocumentStatus,
EnvelopeType,
ReadStatus,
SendStatus,
SigningStatus,
} from '../client';
import { seedPendingDocument } from './documents';
import { seedDirectTemplate, seedTemplate } from './templates';
import { seedUser } from './users';
@ -155,7 +166,6 @@ export const seedDatabase = async () => {
userId: exampleUser.user.id,
teamId: exampleUser.team.id,
}),
seedTemplate({
title: 'Template 1',
userId: adminUser.user.id,
@ -166,5 +176,185 @@ export const seedDatabase = async () => {
userId: adminUser.user.id,
teamId: adminUser.team.id,
}),
seedAlignmentTestDocument({
userId: exampleUser.user.id,
teamId: exampleUser.team.id,
recipientName: exampleUser.user.name || '',
recipientEmail: exampleUser.user.email,
insertFields: false,
status: DocumentStatus.DRAFT,
}),
seedAlignmentTestDocument({
userId: exampleUser.user.id,
teamId: exampleUser.team.id,
recipientName: exampleUser.user.name || '',
recipientEmail: exampleUser.user.email,
insertFields: true,
status: DocumentStatus.PENDING,
}),
seedAlignmentTestDocument({
userId: adminUser.user.id,
teamId: adminUser.team.id,
recipientName: adminUser.user.name || '',
recipientEmail: adminUser.user.email,
insertFields: false,
status: DocumentStatus.DRAFT,
}),
seedAlignmentTestDocument({
userId: adminUser.user.id,
teamId: adminUser.team.id,
recipientName: adminUser.user.name || '',
recipientEmail: adminUser.user.email,
insertFields: true,
status: DocumentStatus.PENDING,
}),
]);
};
export const seedAlignmentTestDocument = async ({
userId,
teamId,
recipientName,
recipientEmail,
insertFields,
status,
}: {
userId: number;
teamId: number;
recipientName: string;
recipientEmail: string;
insertFields: boolean;
status: DocumentStatus;
}) => {
const alignmentPdf = fs
.readFileSync(path.join(__dirname, '../../../assets/field-font-alignment.pdf'))
.toString('base64');
const fieldMetaPdf = fs
.readFileSync(path.join(__dirname, '../../../assets/field-meta.pdf'))
.toString('base64');
const alignmentDocumentData = await createDocumentData({ documentData: alignmentPdf });
const fieldMetaDocumentData = await createDocumentData({ documentData: fieldMetaPdf });
const documentId = await incrementDocumentId();
const documentMeta = await prisma.documentMeta.create({
data: {},
});
const createdEnvelope = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion: 2,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
title: `Envelope Full Field Test`,
status,
envelopeItems: {
createMany: {
data: [
{
id: prefixedId('envelope_item'),
title: `alignment-pdf`,
documentDataId: alignmentDocumentData.id,
order: 1,
},
{
id: prefixedId('envelope_item'),
title: `field-meta-pdf`,
documentDataId: fieldMetaDocumentData.id,
order: 2,
},
],
},
},
userId,
teamId,
recipients: {
create: {
name: recipientName,
email: recipientEmail,
token: nanoid(),
sendStatus: status === 'DRAFT' ? SendStatus.NOT_SENT : SendStatus.SENT,
signingStatus: status === 'COMPLETED' ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
readStatus: status !== 'DRAFT' ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
},
},
},
include: {
recipients: true,
envelopeItems: true,
},
});
const { id, recipients, envelopeItems } = createdEnvelope;
const recipientId = recipients[0].id;
const envelopeItemAlignmentItem = envelopeItems.find((item) => item.order === 1)?.id;
const envelopeItemFieldMetaItem = envelopeItems.find((item) => item.order === 2)?.id;
if (!envelopeItemAlignmentItem || !envelopeItemFieldMetaItem) {
throw new Error('Envelope item not found');
}
await Promise.all(
formatAlignmentTestFields.map(async (field) => {
await prisma.field.create({
data: {
...field,
recipientId,
envelopeItemId: envelopeItemAlignmentItem,
envelopeId: id,
customText: insertFields ? field.customText : '',
inserted: insertFields,
signature: field.signature
? {
create: {
recipientId,
signatureImageAsBase64: isBase64Image(field.signature) ? field.signature : null,
typedSignature: isBase64Image(field.signature) ? null : field.signature,
},
}
: undefined,
},
});
}),
);
await Promise.all(
FIELD_META_TEST_FIELDS.map(async (field) => {
await prisma.field.create({
data: {
...field,
recipientId,
envelopeItemId: envelopeItemFieldMetaItem,
envelopeId: id,
customText: insertFields ? field.customText : '',
inserted: insertFields,
signature: field.signature
? {
create: {
recipientId,
signatureImageAsBase64: isBase64Image(field.signature) ? field.signature : null,
typedSignature: isBase64Image(field.signature) ? null : field.signature,
},
}
: undefined,
},
});
}),
);
return await prisma.envelope.findFirstOrThrow({
where: {
id: createdEnvelope.id,
},
include: {
recipients: true,
envelopeItems: true,
},
});
};

View File

@ -1,17 +1,23 @@
import { createTRPCClient, httpBatchLink, httpLink, splitLink } from '@trpc/client';
import SuperJSON from 'superjson';
import {
createTRPCClient,
httpBatchLink,
httpLink,
isNonJsonSerializable,
splitLink,
} from '@trpc/client';
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,
condition: (op) => op.context.skipBatch === true || isNonJsonSerializable(op.input),
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: SuperJSON,
transformer: dataTransformer,
headers: (opts) => {
if (typeof opts.op.context.teamId === 'string') {
return {
@ -24,7 +30,7 @@ export const trpc = createTRPCClient<AppRouter>({
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: SuperJSON,
transformer: dataTransformer,
headers: (opts) => {
const operationWithTeamId = opts.opList.find(
(op) => op.context.teamId && typeof op.context.teamId === 'string',

View File

@ -12,15 +12,21 @@
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@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",
"@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",
"luxon": "^3.4.0",
"superjson": "^1.13.1",
"trpc-to-openapi": "2.0.4",
"superjson": "^2.2.5",
"trpc-to-openapi": "2.4.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "^3.25.76",
"zod-form-data": "^2.0.8",
"zod-openapi": "^4.2.4"
},
"devDependencies": {
"@types/formidable": "^3.4.6"
}
}

View File

@ -1,13 +1,13 @@
import { useMemo, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, httpLink, splitLink } from '@trpc/client';
import { httpBatchLink, httpLink, isNonJsonSerializable, 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,
condition: (op) => op.context.skipBatch === true || isNonJsonSerializable(op.input),
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
headers,
transformer: SuperJSON,
transformer: dataTransformer,
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers,
transformer: SuperJSON,
transformer: dataTransformer,
}),
}),
],

View File

@ -0,0 +1,136 @@
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.
};
});

View File

@ -0,0 +1,97 @@
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>;

View File

@ -3,20 +3,28 @@ 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';
import {
ZCreateDocumentRequestSchema,
ZCreateDocumentResponseSchema,
createDocumentMeta,
} from './create-document.types';
export const createDocumentRoute = authenticatedProcedure
.input(ZCreateDocumentRequestSchema) // Note: Before releasing this to public, update the response schema to be correct.
.meta(createDocumentMeta)
.input(ZCreateDocumentRequestSchema)
.output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, 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({
input: {
@ -55,6 +63,7 @@ export const createDocumentRoute = authenticatedProcedure
});
return {
legacyDocumentId: mapSecondaryIdToDocumentId(document.secondaryId),
envelopeId: document.id,
id: mapSecondaryIdToDocumentId(document.secondaryId),
};
});

View File

@ -1,23 +1,26 @@
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',
// summary: 'Create document',
// tags: ['Document'],
// },
// };
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 ZCreateDocumentRequestSchema = z.object({
export const ZCreateDocumentPayloadSchema = 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
@ -31,9 +34,16 @@ export const ZCreateDocumentRequestSchema = z.object({
.optional(),
});
export const ZCreateDocumentResponseSchema = z.object({
legacyDocumentId: z.number(),
export const ZCreateDocumentRequestSchema = zodFormData({
payload: zfd.json(ZCreateDocumentPayloadSchema),
file: zfd.file(),
});
export const ZCreateDocumentResponseSchema = z.object({
envelopeId: z.string(),
id: z.number(),
});
export type TCreateDocumentPayloadSchema = z.infer<typeof ZCreateDocumentPayloadSchema>;
export type TCreateDocumentRequest = z.infer<typeof ZCreateDocumentRequestSchema>;
export type TCreateDocumentResponse = z.infer<typeof ZCreateDocumentResponseSchema>;

View File

@ -5,6 +5,7 @@ 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';
@ -40,6 +41,7 @@ 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,

View File

@ -13,7 +13,7 @@ export const createAttachmentRoute = authenticatedProcedure
path: '/envelope/attachment/create',
summary: 'Create attachment',
description: 'Create a new attachment for an envelope',
tags: ['Envelope'],
tags: ['Envelope Attachments'],
},
})
.input(ZCreateAttachmentRequestSchema)

View File

@ -13,7 +13,7 @@ export const deleteAttachmentRoute = authenticatedProcedure
path: '/envelope/attachment/delete',
summary: 'Delete attachment',
description: 'Delete an attachment from an envelope',
tags: ['Envelope'],
tags: ['Envelope Attachments'],
},
})
.input(ZDeleteAttachmentRequestSchema)

View File

@ -2,20 +2,20 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { findAttachmentsByEnvelopeId } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-envelope-id';
import { findAttachmentsByToken } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-token';
import { procedure } from '../../trpc';
import { maybeAuthenticatedProcedure } from '../../trpc';
import {
ZFindAttachmentsRequestSchema,
ZFindAttachmentsResponseSchema,
} from './find-attachments.types';
export const findAttachmentsRoute = procedure
export const findAttachmentsRoute = maybeAuthenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/envelope/attachment',
summary: 'Find attachments',
description: 'Find all attachments for an envelope',
tags: ['Envelope'],
tags: ['Envelope Attachments'],
},
})
.input(ZFindAttachmentsRequestSchema)

View File

@ -13,7 +13,7 @@ export const updateAttachmentRoute = authenticatedProcedure
path: '/envelope/attachment/update',
summary: 'Update attachment',
description: 'Update an existing attachment',
tags: ['Envelope'],
tags: ['Envelope Attachments'],
},
})
.input(ZUpdateAttachmentRequestSchema)

View File

@ -13,11 +13,21 @@ import {
} from './create-envelope-items.types';
export const createEnvelopeItemsRoute = authenticatedProcedure
// Todo: Envelopes - Pending direct uploads
.meta({
openapi: {
method: 'POST',
path: '/envelope/item/create-many',
summary: 'Create envelope items',
description: 'Create multiple envelope items for an envelope',
tags: ['Envelope Items'],
},
})
.input(ZCreateEnvelopeItemsRequestSchema)
.output(ZCreateEnvelopeItemsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { envelopeId, items } = input;
const { envelopeId, data: items } = input;
ctx.logger.info({
input: {

View File

@ -7,7 +7,7 @@ import { ZDocumentTitleSchema } from '../document-router/schema';
export const ZCreateEnvelopeItemsRequestSchema = z.object({
envelopeId: z.string(),
items: z
data: z
.object({
title: ZDocumentTitleSchema,
documentDataId: z.string(),

View File

@ -1,6 +1,7 @@
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 {
@ -9,10 +10,21 @@ import {
} from './create-envelope.types';
export const createEnvelopeRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/create',
summary: 'Create envelope',
tags: ['Envelope'],
},
})
.input(ZCreateEnvelopeRequestSchema)
.output(ZCreateEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { payload, files } = input;
const {
title,
type,
@ -20,12 +32,12 @@ export const createEnvelopeRoute = authenticatedProcedure
visibility,
globalAccessAuth,
globalActionAuth,
formValues,
recipients,
folderId,
items,
meta,
attachments,
} = input;
} = payload;
ctx.logger.info({
input: {
@ -45,13 +57,62 @@ export const createEnvelopeRoute = authenticatedProcedure
});
}
if (items.length > maximumEnvelopeItemCount) {
if (files.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,
@ -60,16 +121,16 @@ export const createEnvelopeRoute = authenticatedProcedure
type,
title,
externalId,
formValues,
visibility,
globalAccessAuth,
globalActionAuth,
recipients,
recipients: recipientsToCreate,
folderId,
envelopeItems: items,
envelopeItems,
},
attachments,
meta,
normalizePdf: true,
requestMetadata: ctx.metadata,
});

View File

@ -1,5 +1,6 @@
import { EnvelopeType } from '@prisma/client';
import { z } from 'zod';
import { zfd } from 'zod-form-data';
import {
ZDocumentAccessAuthTypesSchema,
@ -9,32 +10,36 @@ import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-va
import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import {
ZFieldHeightSchema,
ZClampedFieldHeightSchema,
ZClampedFieldPositionXSchema,
ZClampedFieldPositionYSchema,
ZClampedFieldWidthSchema,
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 {
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',
// summary: 'Create envelope',
// tags: ['Envelope'],
// },
// };
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 ZCreateEnvelopeRequestSchema = z.object({
export const ZCreateEnvelopePayloadSchema = z.object({
title: ZDocumentTitleSchema,
type: z.nativeEnum(EnvelopeType),
externalId: ZDocumentExternalIdSchema.optional(),
@ -42,12 +47,6 @@ export const ZCreateEnvelopeRequestSchema = 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(
@ -59,16 +58,17 @@ export const ZCreateEnvelopeRequestSchema = z.object({
ZCreateRecipientSchema.extend({
fields: ZFieldAndMetaSchema.and(
z.object({
documentDataId: z
.string()
identifier: z
.union([z.string(), z.number()])
.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,
positionX: ZFieldPageXSchema,
positionY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
positionX: ZClampedFieldPositionXSchema,
positionY: ZClampedFieldPositionYSchema,
width: ZClampedFieldWidthSchema,
height: ZClampedFieldHeightSchema,
}),
)
.array()
@ -88,9 +88,15 @@ export const ZCreateEnvelopeRequestSchema = 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>;

View File

@ -12,6 +12,15 @@ import {
} from './delete-envelope-item.types';
export const deleteEnvelopeItemRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/item/delete',
summary: 'Delete envelope item',
description: 'Delete an envelope item from an envelope',
tags: ['Envelope Items'],
},
})
.input(ZDeleteEnvelopeItemRequestSchema)
.output(ZDeleteEnvelopeItemResponseSchema)
.mutation(async ({ input, ctx }) => {

Some files were not shown because too many files have changed in this diff Show More