Compare commits

..

3 Commits

166 changed files with 1512 additions and 7200 deletions

View File

@ -6,7 +6,7 @@ import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/cl
import { DownloadIcon, FileTextIcon } from 'lucide-react'; import { DownloadIcon, FileTextIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file'; import { downloadFile } from '@documenso/lib/client-only/download-file';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@ -87,11 +87,17 @@ export const EnvelopeDownloadDialog = ({
})); }));
try { try {
const downloadUrl = token const data = await getFile({
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${envelopeItemId}/download/${version}` type: envelopeItem.documentData.type,
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`; data:
version === 'signed'
? envelopeItem.documentData.data
: envelopeItem.documentData.initialData,
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob()); const blob = new Blob([data], {
type: 'application/pdf',
});
const baseTitle = envelopeItem.title.replace(/\.pdf$/, ''); const baseTitle = envelopeItem.title.replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.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 { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -54,17 +54,13 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
setIsUploadingFile(true); setIsUploadingFile(true);
try { try {
const payload = { const response = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
title: file.name, title: file.name,
templateDocumentDataId: response.id,
folderId: folderId, folderId: folderId,
} satisfies TCreateTemplatePayloadSchema; });
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({ toast({
title: _(msg`Template document uploaded`), title: _(msg`Template document uploaded`),

View File

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

View File

@ -152,18 +152,6 @@ export const EditorFieldTextForm = ({
className="h-auto" className="h-auto"
placeholder={t`Add text to the field`} placeholder={t`Add text to the field`}
{...field} {...field}
onChange={(e) => {
const values = form.getValues();
const characterLimit = values.characterLimit || 0;
let textValue = e.target.value;
if (characterLimit > 0 && textValue.length > characterLimit) {
textValue = textValue.slice(0, characterLimit);
}
e.target.value = textValue;
field.onChange(e);
}}
rows={1} rows={1}
/> />
</FormControl> </FormControl>
@ -187,18 +175,6 @@ export const EditorFieldTextForm = ({
className="bg-background" className="bg-background"
placeholder={t`Field character limit`} placeholder={t`Field character limit`}
{...field} {...field}
onChange={(e) => {
field.onChange(e);
const values = form.getValues();
const characterLimit = parseInt(e.target.value, 10) || 0;
const textValue = values.text || '';
if (characterLimit > 0 && textValue.length > characterLimit) {
form.setValue('text', textValue.slice(0, characterLimit));
}
}}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@ -89,10 +89,7 @@ export const DirectTemplatePageView = ({
setStep('sign'); setStep('sign');
}; };
const onSignDirectTemplateSubmit = async ( const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
fields: DirectTemplateLocalField[],
nextSigner?: { name: string; email: string },
) => {
try { try {
let directTemplateExternalId = searchParams?.get('externalId') || undefined; let directTemplateExternalId = searchParams?.get('externalId') || undefined;
@ -101,7 +98,6 @@ export const DirectTemplatePageView = ({
} }
const { token } = await createDocumentFromDirectTemplate({ const { token } = await createDocumentFromDirectTemplate({
nextSigner,
directTemplateToken, directTemplateToken,
directTemplateExternalId, directTemplateExternalId,
directRecipientName: fullName, directRecipientName: fullName,

View File

@ -55,13 +55,10 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s
export type DirectTemplateSigningFormProps = { export type DirectTemplateSigningFormProps = {
flowStep: DocumentFlowStep; flowStep: DocumentFlowStep;
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'>; directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
directRecipientFields: Field[]; directRecipientFields: Field[];
template: Omit<TTemplate, 'user'>; template: Omit<TTemplate, 'user'>;
onSubmit: ( onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
_data: DirectTemplateLocalField[],
_nextSigner?: { name: string; email: string },
) => Promise<void>;
}; };
export type DirectTemplateLocalField = Field & { export type DirectTemplateLocalField = Field & {
@ -152,7 +149,7 @@ export const DirectTemplateSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation); validateFieldsInserted(fieldsRequiringValidation);
}; };
const handleSubmit = async (nextSigner?: { name: string; email: string }) => { const handleSubmit = async () => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation); const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
@ -164,7 +161,7 @@ export const DirectTemplateSigningForm = ({
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await onSubmit(localFields, nextSigner); await onSubmit(localFields);
} catch { } catch {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -221,30 +218,6 @@ export const DirectTemplateSigningForm = ({
setLocalFields(updatedFields); setLocalFields(updatedFields);
}, []); }, []);
const nextRecipient = useMemo(() => {
if (
!template.templateMeta?.signingOrder ||
template.templateMeta.signingOrder !== 'SEQUENTIAL' ||
!template.templateMeta.allowDictateNextSigner
) {
return undefined;
}
const sortedRecipients = template.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
return ( return (
<DocumentSigningRecipientProvider recipient={directRecipient}> <DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} /> <DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
@ -444,15 +417,11 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningCompleteDialog <DocumentSigningCompleteDialog
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)} onSignatureComplete={async () => handleSubmit()}
documentTitle={template.title} documentTitle={template.title}
fields={localFields} fields={localFields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
recipient={directRecipient} recipient={directRecipient}
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
/> />
</div> </div>
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>

View File

@ -9,7 +9,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentSigningAuthPageViewProps = { export type DocumentSigningAuthPageViewProps = {
email?: string; email: string;
emailHasAccount?: boolean; emailHasAccount?: boolean;
}; };
@ -22,18 +22,12 @@ export const DocumentSigningAuthPageView = ({
const [isSigningOut, setIsSigningOut] = useState(false); const [isSigningOut, setIsSigningOut] = useState(false);
const handleChangeAccount = async (email?: string) => { const handleChangeAccount = async (email: string) => {
try { try {
setIsSigningOut(true); setIsSigningOut(true);
let redirectPath = '/signin';
if (email) {
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
}
await authClient.signOut({ await authClient.signOut({
redirectPath, redirectPath: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`,
}); });
} catch { } catch {
toast({ toast({
@ -55,13 +49,9 @@ export const DocumentSigningAuthPageView = ({
</h1> </h1>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
{email ? (
<Trans> <Trans>
You need to be logged in as <strong>{email}</strong> to view this page. You need to be logged in as <strong>{email}</strong> to view this page.
</Trans> </Trans>
) : (
<Trans>You need to be logged in to view this page.</Trans>
)}
</p> </p>
<Button <Button

View File

@ -24,10 +24,7 @@ type PasskeyData = {
isError: boolean; isError: boolean;
}; };
type SigningAuthRecipient = Pick< type SigningAuthRecipient = Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
Recipient,
'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'
>;
export type DocumentSigningAuthContextValue = { export type DocumentSigningAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>; executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;

View File

@ -304,6 +304,7 @@ export const DocumentSigningCompleteDialog = ({
<form onSubmit={form.handleSubmit(onFormSubmit)}> <form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && ( {allowDictateNextSigner && defaultNextSigner && (
<div className="mb-4 flex flex-col gap-4"> <div className="mb-4 flex flex-col gap-4">
{/* Todo: Envelopes - Should we say "The next recipient to sign this document will be"? */}
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-4 md:flex-row">
<FormField <FormField
control={form.control} control={form.control}

View File

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

View File

@ -13,7 +13,6 @@ import { prop, sortBy } from 'remeda';
import { isBase64Image } from '@documenso/lib/constants/signatures'; import { isBase64Image } from '@documenso/lib/constants/signatures';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing'; import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { import {
isFieldUnsignedAndRequired, isFieldUnsignedAndRequired,
isRequiredField, isRequiredField,
@ -52,11 +51,7 @@ export type EnvelopeSigningContextValue = {
setSelectedAssistantRecipientId: (_value: number | null) => void; setSelectedAssistantRecipientId: (_value: number | null) => void;
selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null; selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
signField: ( signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise<void>;
_fieldId: number,
_value: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => Promise<void>;
}; };
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null); const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
@ -289,11 +284,9 @@ export const EnvelopeSigningProvider = ({
: null; : null;
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]); }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async ( const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
fieldId: number, console.log('insertField', fieldId, fieldValue);
fieldValue: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => {
// Set the field locally for direct templates. // Set the field locally for direct templates.
if (isDirectTemplate) { if (isDirectTemplate) {
handleDirectTemplateFieldInsertion(fieldId, fieldValue); handleDirectTemplateFieldInsertion(fieldId, fieldValue);
@ -304,7 +297,7 @@ export const EnvelopeSigningProvider = ({
token: envelopeData.recipient.token, token: envelopeData.recipient.token,
fieldId, fieldId,
fieldValue, fieldValue,
authOptions, authOptions: undefined,
}); });
}; };

View File

@ -174,7 +174,7 @@ const DocumentCertificateQrV2 = ({
<div className="mt-12 w-full"> <div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} /> <EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
</div> </div>
</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 { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -62,18 +62,14 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
try { try {
setIsLoading(true); setIsLoading(true);
const payload = { const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
title: file.name, title: file.name,
timezone: userTimezone, documentDataId: response.id,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
folderId: folderId ?? undefined, folderId: folderId ?? undefined,
} satisfies TCreateDocumentPayloadSchema; });
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
void refreshLimits(); void refreshLimits();

View File

@ -13,9 +13,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { import {
@ -73,18 +73,14 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
try { try {
setIsLoading(true); setIsLoading(true);
const payload = { const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
title: file.name, title: file.name,
documentDataId: response.id,
timezone: userTimezone, timezone: userTimezone,
folderId: folderId ?? undefined, folderId: folderId ?? undefined,
} satisfies TCreateDocumentPayloadSchema; });
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
void refreshLimits(); void refreshLimits();

View File

@ -14,9 +14,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload'; import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { import {
@ -78,24 +78,35 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
try { try {
setIsLoading(true); setIsLoading(true);
const payload = { const result = await Promise.all(
files.map(async (file) => {
try {
const response = await putPdfFile(file);
return {
title: file.name,
documentDataId: response.id,
};
} catch (err) {
console.error(err);
throw new Error('Failed to upload document');
}
}),
);
const envelopeItemsToCreate = result.filter(
(item): item is { title: string; documentDataId: string } => item !== undefined,
);
const { id } = await createEnvelope({
folderId, folderId,
type, type,
title: files[0].name, title: files[0].name,
items: envelopeItemsToCreate,
meta: { meta: {
timezone: userTimezone, timezone: userTimezone,
}, },
} satisfies TCreateEnvelopePayload; }).catch((error) => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEnvelope(formData).catch((error) => {
console.error(error); console.error(error);
throw error; throw error;

View File

@ -26,7 +26,7 @@ import { fieldButtonList } from './envelope-editor-fields-drag-drop';
export default function EnvelopeEditorFieldsPageRenderer() { export default function EnvelopeEditorFieldsPageRenderer() {
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const interactiveTransformer = useRef<Transformer | null>(null); const interactiveTransformer = useRef<Transformer | null>(null);
@ -103,6 +103,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
fieldUpdates.height = fieldPageHeight; fieldUpdates.height = fieldPageHeight;
} }
// Todo: envelopes Use id
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates); editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
// Select the field if it is not already selected. // Select the field if it is not already selected.
@ -113,7 +114,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
pageLayer.current?.batchDraw(); pageLayer.current?.batchDraw();
}; };
const unsafeRenderFieldOnLayer = (field: TLocalField) => { const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) { if (!pageLayer.current) {
return; return;
} }
@ -159,15 +160,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
fieldGroup.on('dragend', handleResizeOrMove); 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. * Initialize the Konva page canvas and all fields and interactions.
*/ */

View File

@ -27,8 +27,7 @@ import type {
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
@ -113,34 +112,9 @@ export const EnvelopeEditorFieldsPage = () => {
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */} {/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center"> <div className="mt-4 flex h-full justify-center p-4">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
className="border-border bg-background mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border"
>
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Missing Recipients</Trans>
</AlertTitle>
<AlertDescription>
<Trans>You need at least one recipient to add fields</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline">
<Link to={`${relativePath.editorPath}`}>
<Trans>Add Recipients</Trans>
</Link>
</Button>
</Alert>
)}
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy <PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
renderer="editor"
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
/>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" /> <FileTextIcon className="text-muted-foreground h-10 w-10" />
@ -156,7 +130,7 @@ export const EnvelopeEditorFieldsPage = () => {
</div> </div>
{/* Right Section - Form Fields Panel */} {/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && ( {currentEnvelopeItem && (
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4"> <div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4">
{/* Recipient selector section. */} {/* Recipient selector section. */}
<section className="px-4"> <section className="px-4">
@ -164,6 +138,19 @@ export const EnvelopeEditorFieldsPage = () => {
<Trans>Selected Recipient</Trans> <Trans>Selected Recipient</Trans>
</h3> </h3>
{envelope.recipients.length === 0 ? (
<Alert variant="warning">
<AlertDescription className="flex flex-col gap-2">
<Trans>You need at least one recipient to add fields</Trans>
<Link to={`${relativePath.editorPath}`} className="text-sm">
<p>
<Trans>Click here to add a recipient</Trans>
</p>
</Link>
</AlertDescription>
</Alert>
) : (
<RecipientSelector <RecipientSelector
selectedRecipient={editorFields.selectedRecipient} selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => onSelectedRecipientChange={(recipient) =>
@ -173,6 +160,7 @@ export const EnvelopeEditorFieldsPage = () => {
className="w-full" className="w-full"
align="end" align="end"
/> />
)}
{editorFields.selectedRecipient && {editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && ( !canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (

View File

@ -229,6 +229,7 @@ export const EnvelopeEditorSettingsDialog = ({
const emails = emailData?.data || []; const emails = emailData?.data || [];
// Todo: Envelopes this doesn't make sense (look at previous)
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility); const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
const onFormSubmit = async (data: TAddSettingsFormSchema) => { const onFormSubmit = async (data: TAddSettingsFormSchema) => {
@ -241,6 +242,7 @@ export const EnvelopeEditorSettingsDialog = ({
try { try {
await updateEnvelope({ await updateEnvelope({
envelopeId: envelope.id, envelopeId: envelope.id,
envelopeType: envelope.type,
data: { data: {
externalId: data.externalId || null, externalId: data.externalId || null,
visibility: data.visibility, visibility: data.visibility,
@ -321,7 +323,7 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0"> <DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */} {/* Sidebar. */}
<div className="bg-accent/20 flex w-80 flex-col border-r"> <div className="flex w-80 flex-col border-r bg-gray-50">
<DialogHeader className="p-6 pb-4"> <DialogHeader className="p-6 pb-4">
<DialogTitle>Document Settings</DialogTitle> <DialogTitle>Document Settings</DialogTitle>
</DialogHeader> </DialogHeader>

View File

@ -142,7 +142,7 @@ export const EnvelopeEditorUploadPage = () => {
const { createdEnvelopeItems } = await createEnvelopeItems({ const { createdEnvelopeItems } = await createEnvelopeItems({
envelopeId: envelope.id, envelopeId: envelope.id,
data: envelopeItemsToCreate, items: envelopeItemsToCreate,
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
@ -203,6 +203,7 @@ export const EnvelopeEditorUploadPage = () => {
debouncedUpdateEnvelopeItems(items); debouncedUpdateEnvelopeItems(items);
}; };
// Todo: Envelopes - Sync into envelopes data
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => { const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
void updateEnvelopeItems({ void updateEnvelopeItems({
envelopeId: envelope.id, envelopeId: envelope.id,

View File

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

View File

@ -10,17 +10,14 @@ import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-rende
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates'; import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFullFieldSchema } from '@documenso/lib/types/field'; import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items'; import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip'; import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors'; import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field'; import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field';
import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field'; import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
@ -31,24 +28,20 @@ import { handleNumberFieldClick } from '~/utils/field-signing/number-field';
import { handleSignatureFieldClick } from '~/utils/field-signing/signature-field'; import { handleSignatureFieldClick } from '~/utils/field-signing/signature-field';
import { handleTextFieldClick } from '~/utils/field-signing/text-field'; import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export default function EnvelopeSignerPageRenderer() { export default function EnvelopeSignerPageRenderer() {
const { t, i18n } = useLingui(); const { i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession(); const { sessionData } = useOptionalSession();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const { toast } = useToast();
const { const {
envelopeData, envelopeData,
recipient, recipient,
recipientFields, recipientFields,
recipientFieldsRemaining, recipientFieldsRemaining,
showPendingFieldTooltip, showPendingFieldTooltip,
signField: signFieldInternal, signField,
email, email,
setEmail, setEmail,
fullName, fullName,
@ -87,7 +80,7 @@ export default function EnvelopeSignerPageRenderer() {
); );
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]); }, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => { const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) { if (!pageLayer.current) {
console.error('Layer not loaded yet'); console.error('Layer not loaded yet');
return; return;
@ -244,7 +237,7 @@ export default function EnvelopeSignerPageRenderer() {
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup); fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); await signField(field.id, payload); // Todo: Envelopes - Handle errors
} }
if (payload?.value) { if (payload?.value) {
@ -325,6 +318,7 @@ export default function EnvelopeSignerPageRenderer() {
* SIGNATURE FIELD. * SIGNATURE FIELD.
*/ */
.with({ type: FieldType.SIGNATURE }, (field) => { .with({ type: FieldType.SIGNATURE }, (field) => {
// Todo: Envelopes - Reauth
handleSignatureFieldClick({ handleSignatureFieldClick({
field, field,
signature, signature,
@ -335,21 +329,11 @@ export default function EnvelopeSignerPageRenderer() {
.then(async (payload) => { .then(async (payload) => {
if (payload) { if (payload) {
fieldGroup.add(loadingSpinnerGroup); fieldGroup.add(loadingSpinnerGroup);
if (payload.value) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => {
await signField(field.id, payload, authOptions);
loadingSpinnerGroup.destroy();
},
actionTarget: field.type,
});
setSignature(payload.value);
} else {
await signField(field.id, payload); await signField(field.id, payload);
} }
if (payload?.value) {
setSignature(payload.value);
} }
}) })
.finally(() => { .finally(() => {
@ -363,42 +347,13 @@ export default function EnvelopeSignerPageRenderer() {
fieldGroup.on('pointerdown', handleFieldGroupClick); 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,
authOptions?: TRecipientActionAuth,
) => {
try {
await signFieldInternal(fieldId, payload, authOptions);
} catch (err) {
console.error(err);
toast({
title: t`Error`,
description: t`An error occurred while signing the field.`,
variant: 'destructive',
});
throw err;
}
};
/** /**
* Initialize the Konva page canvas and all fields and interactions. * Initialize the Konva page canvas and all fields and interactions.
*/ */
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
renderFieldOnLayer(field); renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
} }
currentPageLayer.batchDraw(); currentPageLayer.batchDraw();
@ -414,7 +369,7 @@ export default function EnvelopeSignerPageRenderer() {
localPageFields.forEach((field) => { localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas'); console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field); renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
}); });
pageLayer.current.batchDraw(); pageLayer.current.batchDraw();
@ -432,7 +387,7 @@ export default function EnvelopeSignerPageRenderer() {
pageLayer.current.destroyChildren(); pageLayer.current.destroyChildren();
localPageFields.forEach((field) => { localPageFields.forEach((field) => {
renderFieldOnLayer(field); renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
}); });
pageLayer.current.batchDraw(); pageLayer.current.batchDraw();

View File

@ -127,7 +127,6 @@ export const EnvelopeSignerCompleteDialog = () => {
isBase64, isBase64,
}; };
}), }),
nextSigner,
}); });
const redirectUrl = envelope.documentMeta.redirectUrl; const redirectUrl = envelope.documentMeta.redirectUrl;

View File

@ -282,18 +282,6 @@ export const OrgMenuSwitcher = () => {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/settings/profile">
<Trans>Account</Trans>
</Link>
</DropdownMenuItem>
{currentOrganisation && {currentOrganisation &&
canExecuteOrganisationAction( canExecuteOrganisationAction(
'MANAGE_ORGANISATION', 'MANAGE_ORGANISATION',
@ -314,6 +302,18 @@ export const OrgMenuSwitcher = () => {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/inbox">
<Trans>Personal Inbox</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link to="/settings/profile">
<Trans>Account</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-muted-foreground px-4 py-2" className="text-muted-foreground px-4 py-2"
onClick={() => setLanguageSwitcherOpen(true)} onClick={() => setLanguageSwitcherOpen(true)}

View File

@ -0,0 +1,74 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CreditCardIcon } from 'lucide-react';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
export const AccountBillingOrganisations = () => {
const { _ } = useLingui();
const { user, organisations } = useSession();
if (!IS_BILLING_ENABLED()) {
return null;
}
// Filter to only organisations where user can manage billing
const billingOrganisations = organisations.filter((org) =>
canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole),
);
if (billingOrganisations.length === 0) {
return null;
}
return (
<div className="max-w-xl">
<h3 className="text-foreground mb-2 text-lg font-semibold">
<Trans>Billing Management</Trans>
</h3>
<p className="text-muted-foreground mb-4 text-sm">
<Trans>Manage billing for organisations where you have billing permissions.</Trans>
</p>
<div className="space-y-3">
{billingOrganisations.map((org) => (
<Card key={org.id} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<AvatarWithText
avatarSrc={formatAvatarUrl(org.avatarImageId)}
avatarClass="h-10 w-10"
avatarFallback={org.name.slice(0, 1).toUpperCase()}
primaryText={<span className="font-medium">{org.name}</span>}
secondaryText={
org.ownerUserId === user.id
? _(msg`Owner`)
: _(ORGANISATION_MEMBER_ROLE_MAP[org.currentOrganisationRole])
}
/>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/o/${org.url}/settings/billing`}>
<CreditCardIcon className="mr-2 h-4 w-4" />
<Trans>Manage Billing</Trans>
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
};

View File

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

View File

@ -10,6 +10,7 @@ import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animat
import { AccountDeleteDialog } from '~/components/dialogs/account-delete-dialog'; import { AccountDeleteDialog } from '~/components/dialogs/account-delete-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image'; import { AvatarImageForm } from '~/components/forms/avatar-image';
import { ProfileForm } from '~/components/forms/profile'; import { ProfileForm } from '~/components/forms/profile';
import { AccountBillingOrganisations } from '~/components/general/organisations/account-billing-organisations';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { TeamEmailUsage } from '~/components/general/teams/team-email-usage'; import { TeamEmailUsage } from '~/components/general/teams/team-email-usage';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
@ -47,6 +48,8 @@ export default function SettingsProfile() {
)} )}
</AnimatePresence> </AnimatePresence>
<AccountBillingOrganisations />
<AccountDeleteDialog /> <AccountDeleteDialog />
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/env
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing'; import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token'; import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
@ -97,12 +98,15 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
envelopeForSigning, envelopeForSigning,
} as const; } as const;
}) })
.catch((e) => { .catch(async (e) => {
const error = AppError.parseError(e); const error = AppError.parseError(e);
if (error.code === AppErrorCode.UNAUTHORIZED) { if (error.code === AppErrorCode.UNAUTHORIZED) {
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
return { return {
isDocumentAccessValid: false, isDocumentAccessValid: false,
...requiredAccessData,
} as const; } as const;
} }
@ -222,21 +226,20 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
const user = sessionData?.user; const user = sessionData?.user;
if (!data.isDocumentAccessValid) { if (!data.isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />; return (
<DocumentSigningAuthPageView
email={data.recipientEmail}
emailHasAccount={!!data.recipientHasAccount}
/>
);
} }
const { envelope, recipient } = data.envelopeForSigning; const { envelope, recipient } = data.envelopeForSigning;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
});
const isEmailForced = derivedRecipientAccessAuth.includes(DocumentAccessAuth.ACCOUNT);
return ( return (
<EnvelopeSigningProvider <EnvelopeSigningProvider
envelopeData={data.envelopeForSigning} envelopeData={data.envelopeForSigning}
email={isEmailForced ? user?.email || '' : ''} // Doing this allows us to let users change the email if they want to for non-auth templates. email={''} // Doing this allows us to let users change the email if they want to.
fullName={user?.name} fullName={user?.name}
signature={user?.signature} signature={user?.signature}
> >

View File

@ -1,6 +1,5 @@
import { FieldType } from '@prisma/client'; 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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TFieldCheckbox } from '@documenso/lib/types/field'; import type { TFieldCheckbox } from '@documenso/lib/types/field';
import { parseCheckboxCustomText } from '@documenso/lib/utils/fields'; import { parseCheckboxCustomText } from '@documenso/lib/utils/fields';
@ -45,13 +44,6 @@ export const handleCheckboxFieldClick = async (
let checkedValues: number[] | null = newValues.filter((v) => v.isChecked).map((v) => v.index); 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) { if (validationRule && validationLength) {
const checkboxValidationRule = checkboxValidationSigns.find( const checkboxValidationRule = checkboxValidationSigns.find(
(sign) => sign.label === validationRule, (sign) => sign.label === validationRule,
@ -63,34 +55,13 @@ export const handleCheckboxFieldClick = async (
}); });
} }
// 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,
);
// Only render validation dialog if validation is invalid.
if (!isValid) {
checkedValues = await SignFieldCheckboxDialog.call({ checkedValues = await SignFieldCheckboxDialog.call({
fieldMeta: field.fieldMeta, fieldMeta: field.fieldMeta,
validationRule: checkboxValidationRule.value, validationRule: checkboxValidationRule.value,
validationLength, validationLength,
preselectedIndices: checkedValues, preselectedIndices: currentCheckedIndices,
}); });
} }
}
if (!checkedValues) { if (!checkedValues) {
return null; return null;

View File

@ -1,82 +0,0 @@
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,22 +1,21 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { sValidator } from '@hono/standard-validator'; import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono'; 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 { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { putFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import {
import { prisma } from '@documenso/prisma'; getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import type { HonoEnv } from '../router'; import type { HonoEnv } from '../router';
import { handleEnvelopeItemFileRequest } from './files.helpers';
import { import {
type TGetPresignedGetUrlResponse,
type TGetPresignedPostUrlResponse, type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema, ZGetPresignedGetUrlRequestSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema, ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema, ZUploadPdfRequestSchema,
} from './files.types'; } from './files.types';
@ -43,7 +42,29 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'File too large' }, 400); return c.json({ error: 'File too large' }, 400);
} }
const result = await putNormalizedPdfFileServerSide(file); 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 });
return c.json(result); return c.json(result);
} catch (error) { } catch (error) {
@ -51,6 +72,19 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Upload failed' }, 500); 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) => { .post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const { fileName, contentType } = c.req.valid('json'); const { fileName, contentType } = c.req.valid('json');
@ -63,222 +97,4 @@ export const filesRoute = new Hono<HonoEnv>()
throw new AppError(AppErrorCode.UNKNOWN_ERROR); 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,43 +24,15 @@ export const ZGetPresignedPostUrlResponseSchema = z.object({
url: z.string().min(1), 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 TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>; export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export type TGetPresignedGetUrlRequest = z.infer<typeof ZGetPresignedGetUrlRequestSchema>;
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({ export type TGetPresignedGetUrlResponse = z.infer<typeof ZGetPresignedGetUrlResponseSchema>;
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 { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server'; import { auth } from '@documenso/auth/server';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app'; import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client'; import { jobsClient } from '@documenso/lib/jobs/client';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address'; import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { logger } from '@documenso/lib/utils/logger'; import { logger } from '@documenso/lib/utils/logger';
@ -89,22 +89,9 @@ app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler()); app.use('/api/jobs/*', jobsClient.getApiHandler());
app.use('/api/trpc/*', reactRouterTrpcServer); 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. // Unstable API server routes. Order matters for these two.
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument)); 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}/*`, cors());
app.use(`${API_V2_BETA_URL}/*`, async (c) => app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
openApiTrpcServerHandler(c, {
isBeta: true,
}),
);
export default app; export default app;

View File

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

Binary file not shown.

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/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0", "@commitlint/config-conventional": "^17.7.0",
"@lingui/cli": "^5.2.0", "@lingui/cli": "^5.2.0",
"@prisma/client": "^6.18.0", "@prisma/client": "^6.8.2",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0", "dotenv-cli": "^8.0.0",
"eslint": "^8.40.0", "eslint": "^8.40.0",
@ -54,21 +54,11 @@
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"playwright": "1.52.0", "playwright": "1.52.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prisma": "^6.18.0", "prisma": "^6.8.2",
"prisma-extension-kysely": "^3.0.0", "prisma-extension-kysely": "^3.0.0",
"prisma-kysely": "^1.8.0", "prisma-kysely": "^1.8.0",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3", "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" "vite": "^6.3.5"
}, },
"name": "@documenso/root", "name": "@documenso/root",
@ -86,10 +76,10 @@
"mupdf": "^1.0.0", "mupdf": "^1.0.0",
"react": "^18", "react": "^18",
"typescript": "5.6.2", "typescript": "5.6.2",
"zod": "^3.25.76" "zod": "3.24.1"
}, },
"overrides": { "overrides": {
"zod": "^3.25.76" "zod": "3.24.1"
}, },
"trigger.dev": { "trigger.dev": {
"endpointId": "documenso-app" "endpointId": "documenso-app"

View File

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

View File

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

View File

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

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

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

@ -1,282 +0,0 @@
// 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

@ -78,6 +78,7 @@ test.describe('Signing Certificate Tests', () => {
}, },
}); });
// Todo: Envelopes
const firstDocumentData = completedDocument.envelopeItems[0].documentData; const firstDocumentData = completedDocument.envelopeItems[0].documentData;
const completedDocumentData = await getFile(firstDocumentData); const completedDocumentData = await getFile(firstDocumentData);
@ -168,6 +169,7 @@ test.describe('Signing Certificate Tests', () => {
}, },
}); });
// Todo: Envelopes
const firstDocumentData = completedDocument.envelopeItems[0].documentData; const firstDocumentData = completedDocument.envelopeItems[0].documentData;
const completedDocumentData = await getFile(firstDocumentData); const completedDocumentData = await getFile(firstDocumentData);

View File

@ -1,12 +1,9 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth'; import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates'; import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users'; import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
@ -124,7 +121,7 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
await expect(page.getByText('404 not found')).toBeVisible(); await expect(page.getByText('404 not found')).toBeVisible();
}); });
test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page }) => { test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => {
const { user, team } = await seedUser(); const { user, team } = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({ const directTemplateWithAuth = await seedDirectTemplate({
@ -156,53 +153,6 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeDisabled(); await expect(page.getByLabel('Email')).toBeDisabled();
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
});
test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page }) => {
const { user, team } = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({
title: 'Personal direct template link',
userId: user.id,
teamId: team.id,
internalVersion: 2,
createTemplateOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: [],
}),
},
});
const directTemplatePath = formatDirectTemplatePath(
directTemplateWithAuth.directLink?.token || '',
);
await page.goto(directTemplatePath);
await expect(page.getByText('Authentication required')).toBeVisible();
await apiSignin({
page,
email: user.email,
});
await page.goto(directTemplatePath);
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByLabel('Your Email')).not.toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
}); });
test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => { test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => {
@ -225,9 +175,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail()); await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click(); await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/); await page.waitForURL(/\/sign/);
@ -236,173 +183,3 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
// Add a longer waiting period to ensure document status is updated // Add a longer waiting period to ensure document status is updated
await page.waitForTimeout(3000); await page.waitForTimeout(3000);
}); });
test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with next signer dictation', async ({
page,
}) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
// Should be visible to team members.
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
});
await prisma.documentMeta.update({
where: {
id: template.documentMetaId,
},
data: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
email: originalSecondSignerEmail,
name: originalName,
token: Math.random().toString().slice(2, 7),
role: RecipientRole.SIGNER,
},
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.waitForTimeout(100);
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByText('Next Recipient Name')).toBeVisible();
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
expect(nextRecipientNameInputValue).toBe(originalName);
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
const newName = 'Hello';
const newSecondSignerEmail = seedTestEmail();
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
await page.getByLabel('Next Recipient Name').fill(newName);
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
const createdEnvelopeRecipients = await prisma.recipient.findMany({
where: {
envelope: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
});
test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with next signer dictation', async ({
page,
}) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
// Should be visible to team members.
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
internalVersion: 2,
});
await prisma.documentMeta.update({
where: {
id: template.documentMetaId,
},
data: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
email: originalSecondSignerEmail,
name: originalName,
token: Math.random().toString().slice(2, 7),
role: RecipientRole.SIGNER,
},
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Team direct template link 1' })).toBeVisible();
await page.waitForTimeout(100);
await page.getByRole('button', { name: 'Complete' }).click();
const currentName = 'John Doe';
const currentEmail = seedTestEmail();
await page.getByPlaceholder('Enter Your Name').fill(currentName);
await page.getByPlaceholder('Enter Your Email').fill(currentEmail);
await expect(page.getByText('Next Recipient Name')).toBeVisible();
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
expect(nextRecipientNameInputValue).toBe(originalName);
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
const newName = 'Hello';
const newSecondSignerEmail = seedTestEmail();
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
await page.getByLabel('Next Recipient Name').fill(newName);
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
const createdEnvelopeRecipients = await prisma.recipient.findMany({
where: {
envelope: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
});

View File

@ -15,10 +15,7 @@
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@playwright/test": "1.52.0", "@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": { "dependencies": {
"start-server-and-test": "^2.0.12" "start-server-and-test": "^2.0.12"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,7 +55,7 @@
"skia-canvas": "^3.0.8", "skia-canvas": "^3.0.8",
"stripe": "^12.7.0", "stripe": "^12.7.0",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"zod": "^3.25.76" "zod": "3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/browser-chromium": "1.52.0", "@playwright/browser-chromium": "1.52.0",

View File

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

View File

@ -16,16 +16,11 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { nanoid, prefixedId } from '@documenso/lib/universal/id'; import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
TDocumentAccessAuthTypes,
TDocumentActionAuthTypes,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
} from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values'; import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment'; import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta';
import { import {
ZWebhookDocumentSchema, ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload, mapEnvelopeToWebhookDocumentPayload,
@ -39,25 +34,6 @@ import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
documentDataId: string;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
type CreateEnvelopeRecipientOptions = {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
fields?: CreateEnvelopeRecipientFieldOptions[];
};
export type CreateEnvelopeOptions = { export type CreateEnvelopeOptions = {
userId: number; userId: number;
teamId: number; teamId: number;
@ -70,6 +46,7 @@ export type CreateEnvelopeOptions = {
envelopeItems: { title?: string; documentDataId: string; order?: number }[]; envelopeItems: { title?: string; documentDataId: string; order?: number }[];
formValues?: TDocumentFormValues; formValues?: TDocumentFormValues;
timezone?: string;
userTimezone?: string; userTimezone?: string;
templateType?: TemplateType; templateType?: TemplateType;
@ -79,7 +56,7 @@ export type CreateEnvelopeOptions = {
visibility?: DocumentVisibility; visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[]; globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[]; globalActionAuth?: TDocumentActionAuthTypes[];
recipients?: CreateEnvelopeRecipientOptions[]; recipients?: TCreateEnvelopeRequest['recipients'];
folderId?: string; folderId?: string;
}; };
attachments?: Array<{ attachments?: Array<{
@ -106,6 +83,7 @@ export const createEnvelope = async ({
title, title,
externalId, externalId,
formValues, formValues,
timezone,
userTimezone, userTimezone,
folderId, folderId,
templateType, templateType,
@ -164,7 +142,6 @@ export const createEnvelope = async ({
let envelopeItems: { title?: string; documentDataId: string; order?: number }[] = let envelopeItems: { title?: string; documentDataId: string; order?: number }[] =
data.envelopeItems; data.envelopeItems;
// Todo: Envelopes - Remove
if (normalizePdf) { if (normalizePdf) {
envelopeItems = await Promise.all( envelopeItems = await Promise.all(
data.envelopeItems.map(async (item) => { data.envelopeItems.map(async (item) => {
@ -242,7 +219,7 @@ export const createEnvelope = async ({
// userTimezone is last because it's always passed in regardless of the organisation/team settings // userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend // for uploads from the frontend
const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone; const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
const documentMeta = await prisma.documentMeta.create({ const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, { data: extractDerivedDocumentMeta(settings, {

View File

@ -1,11 +1,10 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client'; import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth'; import type { TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing'; import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing'; import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@ -99,28 +98,14 @@ export const getEnvelopeForDirectTemplateSigning = async ({
}); });
} }
// Currently not using this since for direct templates "User" access means they just need to be const documentAccessValid = await isRecipientAuthorized({
// logged in. type: 'ACCESS',
// const documentAccessValid = await isRecipientAuthorized({ documentAuthOptions: envelope.authOptions,
// type: 'ACCESS', recipient,
// documentAuthOptions: envelope.authOptions, userId,
// recipient, authOptions: accessAuth,
// userId,
// authOptions: accessAuth,
// });
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
}); });
// Ensure typesafety when we add more options.
const documentAccessValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(userId))
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
.exhaustive(),
);
if (!documentAccessValid) { if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values', message: 'Invalid access values',

View File

@ -54,3 +54,54 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
recipientHasAccount: Boolean(recipientUserAccount), recipientHasAccount: Boolean(recipientUserAccount),
} as const; } as const;
}; };
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
directLink: {
enabled: true,
token,
},
status: DocumentStatus.DRAFT,
},
include: {
recipients: {
where: {
token,
},
},
directLink: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const recipient = envelope.recipients.find(
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const recipientUserAccount = await prisma.user.findFirst({
where: {
email: recipient.email.toLowerCase(),
},
select: {
id: true,
},
});
return {
recipientEmail: recipient.email,
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};

View File

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

View File

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

View File

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

View File

@ -10,21 +10,18 @@ import {
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields'; import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients'; import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateEnvelopeFieldsOptions { export interface UpdateDocumentFieldsOptions {
userId: number; userId: number;
teamId: number; teamId: number;
id: EnvelopeIdOptions; documentId: number;
type?: EnvelopeType | null; // Only used to enforce the type.
fields: { fields: {
id: number; id: number;
type?: FieldType; type?: FieldType;
pageNumber?: number; pageNumber?: number;
envelopeItemId?: string;
pageX?: number; pageX?: number;
pageY?: number; pageY?: number;
width?: number; width?: number;
@ -34,17 +31,19 @@ export interface UpdateEnvelopeFieldsOptions {
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
} }
export const updateEnvelopeFields = async ({ export const updateDocumentFields = async ({
userId, userId,
teamId, teamId,
id, documentId,
type = null,
fields, fields,
requestMetadata, requestMetadata,
}: UpdateEnvelopeFieldsOptions) => { }: UpdateDocumentFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({ const { envelopeWhereInput } = await getEnvelopeWhereInput({
id, id: {
type, type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
userId, userId,
teamId, teamId,
}); });
@ -54,19 +53,18 @@ export const updateEnvelopeFields = async ({
include: { include: {
recipients: true, recipients: true,
fields: true, fields: true,
envelopeItems: true,
}, },
}); });
if (!envelope) { if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found', message: 'Document not found',
}); });
} }
if (envelope.completedAt) { if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete', message: 'Document already complete',
}); });
} }
@ -98,29 +96,6 @@ export const updateEnvelopeFields = 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 { return {
originalField, originalField,
updateData: field, updateData: field,
@ -143,14 +118,12 @@ export const updateEnvelopeFields = async ({
width: updateData.width, width: updateData.width,
height: updateData.height, height: updateData.height,
fieldMeta: updateData.fieldMeta, fieldMeta: updateData.fieldMeta,
envelopeItemId: updateData.envelopeItemId,
}, },
}); });
// Handle field updated audit log.
if (envelope.type === EnvelopeType.DOCUMENT) {
const changes = diffFieldChanges(originalField, updatedField); const changes = diffFieldChanges(originalField, updatedField);
// Handle field updated audit log.
if (changes.length > 0) { if (changes.length > 0) {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
@ -167,7 +140,6 @@ export const updateEnvelopeFields = async ({
}), }),
}); });
} }
}
return updatedField; return updatedField;
}), }),

View File

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

View File

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

View File

@ -0,0 +1,115 @@
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,27 +14,26 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { canRecipientBeModified } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams'; import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context'; import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteEnvelopeRecipientOptions { export interface DeleteDocumentRecipientOptions {
userId: number; userId: number;
teamId: number; teamId: number;
recipientId: number; recipientId: number;
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
} }
export const deleteEnvelopeRecipient = async ({ export const deleteDocumentRecipient = async ({
userId, userId,
teamId, teamId,
recipientId, recipientId,
requestMetadata, requestMetadata,
}: DeleteEnvelopeRecipientOptions) => { }: DeleteDocumentRecipientOptions) => {
const envelope = await prisma.envelope.findFirst({ const envelope = await prisma.envelope.findFirst({
where: { where: {
type: EnvelopeType.DOCUMENT,
recipients: { recipients: {
some: { some: {
id: recipientId, id: recipientId,
@ -49,9 +48,6 @@ export const deleteEnvelopeRecipient = async ({
where: { where: {
id: recipientId, id: recipientId,
}, },
include: {
fields: true,
},
}, },
}, },
}); });
@ -93,24 +89,7 @@ export const deleteEnvelopeRecipient = async ({
}); });
} }
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) => { const deletedRecipient = await prisma.$transaction(async (tx) => {
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED, type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
@ -124,12 +103,10 @@ export const deleteEnvelopeRecipient = async ({
}, },
}), }),
}); });
}
return await tx.recipient.delete({ return await tx.recipient.delete({
where: { where: {
id: recipientId, id: recipientId,
envelope: envelopeWhereInput,
}, },
}); });
}); });
@ -139,11 +116,7 @@ export const deleteEnvelopeRecipient = async ({
).recipientRemoved; ).recipientRemoved;
// Send email to deleted recipient. // Send email to deleted recipient.
if ( if (recipientToDelete.sendStatus === SendStatus.SENT && isRecipientRemovedEmailEnabled) {
recipientToDelete.sendStatus === SendStatus.SENT &&
isRecipientRemovedEmailEnabled &&
envelope.type === EnvelopeType.DOCUMENT
) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, { const template = createElement(RecipientRemovedFromDocumentTemplate, {

View File

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

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

@ -3,7 +3,6 @@ import { createElement } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client'; import type { Field, Signature } from '@prisma/client';
import { import {
DocumentSigningOrder,
DocumentSource, DocumentSource,
DocumentStatus, DocumentStatus,
EnvelopeType, EnvelopeType,
@ -27,7 +26,7 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f
import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth'; import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta'; import { ZFieldMetaSchema } from '../../types/field-meta';
@ -69,10 +68,6 @@ export type CreateDocumentFromDirectTemplateOptions = {
name?: string; name?: string;
email: string; email: string;
}; };
nextSigner?: {
email: string;
name: string;
};
}; };
type CreatedDirectRecipientField = { type CreatedDirectRecipientField = {
@ -97,7 +92,6 @@ export const createDocumentFromDirectTemplate = async ({
directTemplateExternalId, directTemplateExternalId,
signedFieldValues, signedFieldValues,
templateUpdatedAt, templateUpdatedAt,
nextSigner,
requestMetadata, requestMetadata,
user, user,
}: CreateDocumentFromDirectTemplateOptions): Promise<TCreateDocumentFromDirectTemplateResponse> => { }: CreateDocumentFromDirectTemplateOptions): Promise<TCreateDocumentFromDirectTemplateResponse> => {
@ -134,17 +128,6 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' }); throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
} }
if (
nextSigner &&
(!directTemplateEnvelope.documentMeta?.allowDictateNextSigner ||
directTemplateEnvelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'You need to enable allowDictateNextSigner and sequential signing to dictate the next signer',
});
}
const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId( const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId(
directTemplateEnvelope.secondaryId, directTemplateEnvelope.secondaryId,
); );
@ -647,77 +630,6 @@ export const createDocumentFromDirectTemplate = async ({
}), }),
]; ];
if (nextSigner) {
const pendingRecipients = await tx.recipient.findMany({
select: {
id: true,
signingOrder: true,
name: true,
email: true,
role: true,
},
where: {
envelopeId: createdEnvelope.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
role: {
not: RecipientRole.CC,
},
},
// Composite sort so our next recipient is always the one with the lowest signing order or id
// if there is a tie.
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
const nextRecipient = pendingRecipients[0];
if (nextRecipient) {
auditLogsToCreate.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: createdEnvelope.id,
user: {
name: user?.name || directRecipientName || '',
email: user?.email || directRecipientEmail,
},
metadata: requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
);
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
}
}
await tx.documentAuditLog.createMany({ await tx.documentAuditLog.createMany({
data: auditLogsToCreate, data: auditLogsToCreate,
}); });

View File

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

View File

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

View File

@ -50,11 +50,6 @@ export const ZFieldSchema = FieldSchema.pick({
templateId: z.number().nullish(), templateId: z.number().nullish(),
}); });
export const ZEnvelopeFieldSchema = ZFieldSchema.omit({
documentId: true,
templateId: true,
});
export const ZFieldPageNumberSchema = z export const ZFieldPageNumberSchema = z
.number() .number()
.min(1) .min(1)
@ -74,30 +69,6 @@ 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 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( const PrismaDecimalSchema = z.preprocess(

View File

@ -95,18 +95,3 @@ export const ZRecipientManySchema = RecipientSchema.pick({
documentId: z.number().nullish(), documentId: z.number().nullish(),
templateId: 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,5 +30,3 @@ export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
return chacha.decrypt(dataAsBytes); return chacha.decrypt(dataAsBytes);
}; };
export { sha256 };

View File

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

View File

@ -3,7 +3,6 @@ import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf'; import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta'; import type { TCheckboxFieldMeta } from '../../types/field-meta';
import { parseCheckboxCustomText } from '../../utils/fields';
import { import {
createFieldHoverInteraction, createFieldHoverInteraction,
konvaTextFill, konvaTextFill,
@ -63,15 +62,16 @@ export const renderCheckboxFieldElement = (
const rectWidth = fieldRect.width() * groupScaleX; const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY; const rectHeight = fieldRect.height() * groupScaleY;
// Todo: Envelopes - check sorting more than 10
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const squares = fieldGroup const squares = fieldGroup
.find('.checkbox-square') .find('.checkbox-square')
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true })); .sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup const checkmarks = fieldGroup
.find('.checkbox-checkmark') .find('.checkbox-checkmark')
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true })); .sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
.find('.checkbox-text')
.sort((a, b) => a.id().localeCompare(b.id(), undefined, { numeric: true }));
const groupedItems = squares.map((square, i) => ({ const groupedItems = squares.map((square, i) => ({
squareElement: square, squareElement: square,
@ -130,7 +130,7 @@ export const renderCheckboxFieldElement = (
pageLayer.batchDraw(); pageLayer.batchDraw();
}); });
const checkedValues: number[] = field.customText ? parseCheckboxCustomText(field.customText) : []; const checkedValues: number[] = field.customText ? JSON.parse(field.customText) : [];
checkboxValues.forEach(({ value, checked }, index) => { checkboxValues.forEach(({ value, checked }, index) => {
const isCheckboxChecked = match(mode) const isCheckboxChecked = match(mode)
@ -170,7 +170,7 @@ export const renderCheckboxFieldElement = (
width: itemSize, width: itemSize,
height: itemSize, height: itemSize,
stroke: '#374151', stroke: '#374151',
strokeWidth: 1.5, strokeWidth: 2,
cornerRadius: 2, cornerRadius: 2,
fill: 'white', fill: 'white',
}); });

View File

@ -8,9 +8,9 @@ import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import type { TFieldMetaSchema } from '../../types/field-meta'; import type { TFieldMetaSchema } from '../../types/field-meta';
import { renderCheckboxFieldElement } from './render-checkbox-field'; import { renderCheckboxFieldElement } from './render-checkbox-field';
import { renderDropdownFieldElement } from './render-dropdown-field'; import { renderDropdownFieldElement } from './render-dropdown-field';
import { renderGenericTextFieldElement } from './render-generic-text-field';
import { renderRadioFieldElement } from './render-radio-field'; import { renderRadioFieldElement } from './render-radio-field';
import { renderSignatureFieldElement } from './render-signature-field'; import { renderSignatureFieldElement } from './render-signature-field';
import { renderTextFieldElement } from './render-text-field';
export const MIN_FIELD_HEIGHT_PX = 12; export const MIN_FIELD_HEIGHT_PX = 12;
export const MIN_FIELD_WIDTH_PX = 36; export const MIN_FIELD_WIDTH_PX = 36;
@ -43,9 +43,9 @@ type RenderFieldOptions = {
* *
* @default 'edit' * @default 'edit'
* *
* - `edit` - The field is rendered in editor page. * - `edit` - The field is rendered in edit mode.
* - `sign` - The field is rendered for the signing page. * - `sign` - The field is rendered in sign mode. No interactive elements.
* - `export` - The field is rendered for exporting and sealing into the PDF. No backgrounds, interactive elements, etc. * - `export` - The field is rendered in export mode. No backgrounds, interactive elements, etc.
*/ */
mode: 'edit' | 'sign' | 'export'; mode: 'edit' | 'sign' | 'export';
@ -76,21 +76,10 @@ export const renderField = ({
}; };
return match(field.type) return match(field.type)
.with( .with(FieldType.TEXT, () => renderTextFieldElement(field, options))
FieldType.INITIALS,
FieldType.NAME,
FieldType.EMAIL,
FieldType.DATE,
FieldType.TEXT,
FieldType.NUMBER,
() => renderGenericTextFieldElement(field, options),
)
.with(FieldType.CHECKBOX, () => renderCheckboxFieldElement(field, options)) .with(FieldType.CHECKBOX, () => renderCheckboxFieldElement(field, options))
.with(FieldType.RADIO, () => renderRadioFieldElement(field, options)) .with(FieldType.RADIO, () => renderRadioFieldElement(field, options))
.with(FieldType.DROPDOWN, () => renderDropdownFieldElement(field, options)) .with(FieldType.DROPDOWN, () => renderDropdownFieldElement(field, options))
.with(FieldType.SIGNATURE, () => renderSignatureFieldElement(field, options)) .with(FieldType.SIGNATURE, () => renderSignatureFieldElement(field, options))
.with(FieldType.FREE_SIGNATURE, () => { .otherwise(() => renderTextFieldElement(field, options)); // Todo: Envelopes
throw new Error('Free signature fields are not supported');
})
.exhaustive();
}; };

View File

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

View File

@ -12,8 +12,6 @@ import {
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer'; import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer'; import { calculateFieldPosition } from './field-renderer';
const DEFAULT_TEXT_ALIGN = 'left';
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => { const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options; const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
@ -33,8 +31,8 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
// Calculate text positioning based on alignment // Calculate text positioning based on alignment
const textX = 0; const textX = 0;
const textY = 0; const textY = 0;
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || DEFAULT_TEXT_ALIGN; let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || 'left';
const textVerticalAlign: 'top' | 'middle' | 'bottom' = 'middle'; let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
const textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE; const textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
const textPadding = 10; const textPadding = 10;
@ -42,33 +40,51 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
// Handle edit mode. // Handle edit mode.
if (mode === 'edit') { if (mode === 'edit') {
if (textMeta?.text) {
textToRender = textMeta.text;
} else if (textMeta?.label) {
textToRender = textMeta.label;
} else {
// Show field name which is centered for the edit mode if no label/text is avaliable.
textToRender = fieldTypeName; textToRender = fieldTypeName;
textAlign = 'center'; textAlign = 'center';
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
} else if (textMeta?.text) {
textToRender = textMeta.text;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
} }
} }
// Handle sign mode. // Handle sign mode.
if (mode === 'sign' || mode === 'export') { if (mode === 'sign' || mode === 'export') {
if (!field.inserted) {
if (textMeta?.text) {
textToRender = textMeta.text;
} else if (textMeta?.label) {
textToRender = textMeta.label;
} else if (mode === 'sign') {
// Only show the field name in sign mode if no text/label is avaliable.
textToRender = fieldTypeName; textToRender = fieldTypeName;
textAlign = 'center'; textAlign = 'center';
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
}
if (textMeta?.text) {
textToRender = textMeta.text;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
} }
} }
if (field.inserted) { if (field.inserted) {
textToRender = field.customText; textToRender = field.customText;
textAlign = textMeta?.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
if (textMeta?.characterLimit) {
textToRender = textToRender.slice(0, textMeta.characterLimit);
}
} }
} }
@ -90,7 +106,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
return fieldText; return fieldText;
}; };
export const renderGenericTextFieldElement = ( export const renderTextFieldElement = (
field: FieldToRender, field: FieldToRender,
options: RenderFieldElementOptions, options: RenderFieldElementOptions,
) => { ) => {

View File

@ -7,13 +7,7 @@ export type GetFileOptions = {
data: string; 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) return await match(type)
.with(DocumentDataType.BYTES, () => getFileFromBytes(data)) .with(DocumentDataType.BYTES, () => getFileFromBytes(data))
.with(DocumentDataType.BYTES_64, () => getFileFromBytes64(data)) .with(DocumentDataType.BYTES_64, () => getFileFromBytes64(data))

View File

@ -7,7 +7,6 @@ import { env } from '@documenso/lib/utils/env';
import { AppError } from '../../errors/app-error'; import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data'; import { createDocumentData } from '../../server-only/document-data/create-document-data';
import { normalizePdf } from '../../server-only/pdf/normalize-pdf';
import { uploadS3File } from './server-actions'; import { uploadS3File } from './server-actions';
type File = { type File = {
@ -44,28 +43,6 @@ export const putPdfFileServerSide = async (file: File) => {
return await createDocumentData({ type, data }); return await createDocumentData({ type, data });
}; };
/**
* Uploads a pdf file and normalizes it.
*/
export const putNormalizedPdfFileServerSide = async (file: File) => {
const buffer = Buffer.from(await file.arrayBuffer());
const normalized = await normalizePdf(buffer);
const fileName = file.name.endsWith('.pdf') ? file.name : `${file.name}.pdf`;
const documentData = await putFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalized),
});
return await createDocumentData({
type: documentData.type,
data: documentData.data,
});
};
/** /**
* Uploads a file to the appropriate storage location. * Uploads a file to the appropriate storage location.
*/ */

View File

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

View File

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

View File

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

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