Compare commits

...

13 Commits

Author SHA1 Message Date
David Nguyen
bb17bb9800 Merge branch 'main' into fix/refactor-use-template 2024-05-07 00:01:09 +07:00
Catalin Pit
e8d4fe46e5 fix: custom email message for self-signers (#1120) 2024-05-06 09:22:50 +03:00
David Nguyen
64e3e2c64b fix: disable encrypted pdfs (#1130)
## Description

Currently if you complete a pending encrypted document, it will prevent
the document from being sealed due to the systems inability to decrypt
it.

This PR disables uploading any documents that cannot be loaded as a
temporary measure.

**Note**
This is a client side only check

## Changes Made

- Disable uploading documents that cannot be parsed
- Refactor putFile to putDocumentFile
- Add a flag as a backup incase something goes wrong
2024-05-03 22:25:24 +07:00
David Nguyen
ec42fbcbcb fix: refactor 2024-05-01 21:40:16 +07:00
David Nguyen
91b4bb52b5 fix: tests 2024-05-01 16:46:23 +07:00
David Nguyen
3209ce8c78 Merge branch 'main' into fix/refactor-use-template 2024-05-01 15:16:25 +07:00
David Nguyen
1e825ede45 fix: refactor 2024-05-01 15:14:00 +07:00
David Nguyen
a25b9a372e Merge branch 'main' into fix/refactor-use-template 2024-04-26 13:25:19 +07:00
David Nguyen
39bd3e5880 fix: enhancements 2024-04-25 22:28:41 +07:00
David Nguyen
ef666b0e70 fix: tests 2024-04-23 15:58:31 +07:00
David Nguyen
9a801d6091 fix: add /api/v1 backwards compat 2024-04-23 14:42:03 +07:00
David Nguyen
193419d169 fix: add template send 2024-04-23 13:12:42 +07:00
David Nguyen
6cba74e128 fix: refactor use template 2024-04-22 16:05:16 +07:00
19 changed files with 445 additions and 274 deletions

View File

@@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
}
try {
const putFileData = await putFile(uploadedFile.file);
const putFileData = await putPdfFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({
documentData: {

View File

@@ -10,8 +10,9 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
@@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
try {
setIsLoading(true);
const { type, data } = await putFile(file);
const { type, data } = await putPdfFile(file);
const { id: documentDataId } = await createDocumentData({
type,
@@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
});
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (error) {
console.error(error);
} catch (err) {
const error = AppError.parseError(err);
if (error instanceof TRPCClientError) {
console.error(err);
if (error.code === 'INVALID_DOCUMENT_FILE') {
toast({
title: 'Invalid file',
description: 'You cannot upload encrypted PDFs',
variant: 'destructive',
});
} else if (err instanceof TRPCClientError) {
toast({
title: 'Error',
description: error.message,
description: err.message,
variant: 'destructive',
});
} else {

View File

@@ -12,7 +12,7 @@ import * as z from 'zod';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -98,7 +98,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const file: File = uploadedFile.file;
try {
const { type, data } = await putFile(file);
const { type, data } = await putPdfFile(file);
const { id: templateDocumentDataId } = await createDocumentData({
type,

View File

@@ -1,14 +1,16 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { InfoIcon, Plus } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod';
import { TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
@@ -19,24 +21,59 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
email: z.string().email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
});
const ZAddRecipientsForNewDocumentSchema = z
.object({
sendDocument: z.boolean(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
}),
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@@ -56,33 +93,31 @@ export function UseTemplateDialog({
const team = useOptionalCurrentTeam();
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddRecipientsForNewDocumentSchema>({
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
recipients:
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
}))
: [
{
name: '',
email: '',
role: RecipientRole.SIGNER,
},
],
sendDocument: false,
recipients: recipients.map((recipient) => {
const isRecipientPlaceholder = recipient.email.match(TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX);
if (isRecipientPlaceholder) {
return {
id: recipient.id,
name: '',
email: '',
};
}
return {
id: recipient.id,
name: recipient.name,
email: recipient.email,
};
}),
},
});
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
@@ -91,6 +126,7 @@ export function UseTemplateDialog({
templateId,
teamId: team?.id,
recipients: data.recipients,
sendDocument: data.sendDocument,
});
toast({
@@ -101,18 +137,24 @@ export function UseTemplateDialog({
router.push(`${documentRootPath}/${id}`);
} catch (err) {
toast({
const error = AppError.parseError(err);
const toastPayload: Toast = {
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
};
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = 'The document was created but could not be sent to recipients.';
}
toast(toastPayload);
}
};
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
const { fields: formRecipients } = useFieldArray({
control,
control: form.control,
name: 'recipients',
});
@@ -126,121 +168,110 @@ export function UseTemplateDialog({
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Document Recipients</DialogTitle>
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
<DialogTitle>Create document from template</DialogTitle>
<DialogDescription>
{recipients.length === 0
? 'A draft document will be created'
: 'Add the recipients to create the document with'}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-4">
{formRecipients.map((recipient, index) => (
<div
key={recipient.id}
data-native-id={recipient.id}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller
control={control}
name={`recipients.${index}.email`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
<FormField
control={form.control}
name={`recipients.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email</FormLabel>}
<FormControl>
<Input {...field} placeholder={recipients[index].email} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
<FormField
control={form.control}
name={`recipients.${index}.name`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel>Name</FormLabel>}
<Controller
control={control}
name={`recipients.${index}.name`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
<FormControl>
<Input {...field} placeholder={recipients[index].name} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</div>
))}
</div>
<div className="w-[60px]">
<Controller
control={control}
name={`recipients.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
{recipients.length > 0 && (
<div className="mt-4 flex flex-row items-center">
<FormField
control={form.control}
name="sendDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="sendDocument"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="sendDocument"
>
Send document
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
The document will be immediately sent to recipients if this is
checked.
</p>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<p>Otherwise, the document will be created as a draft.</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
</div>
)}
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
</div>
</div>
))}
</div>
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button
type="button"
loading={isCreatingDocumentFromTemplate}
disabled={isCreatingDocumentFromTemplate}
onClick={onCreateDocumentFromTemplate}
>
Create Document
</Button>
</DialogFooter>
<Button type="submit" loading={form.formState.isSubmitting}>
{form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@@ -22,7 +22,7 @@ import { updateRecipient } from '@documenso/lib/server-only/recipient/update-rec
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import {
getPresignGetUrl,
getPresignPostUrl,
@@ -303,7 +303,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
formValues: body.formValues,
});
const newDocumentData = await putFile({
const newDocumentData = await putPdfFile({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),

View File

@@ -100,13 +100,21 @@ export type TCreateDocumentMutationResponseSchema = z.infer<
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
title: z.string().min(1),
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
}),
),
recipients: z.union([
z.array(
z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email().min(1),
}),
),
z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
}),
),
]),
meta: z
.object({
subject: z.string(),

View File

@@ -189,7 +189,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents');
@@ -200,7 +207,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use team template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`);

View File

@@ -5,7 +5,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { alphaid, nanoid } from '@documenso/lib/universal/id';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
@@ -74,7 +74,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
).then(async (res) => res.arrayBuffer());
const { id: documentDataId } = await putFile({
const { id: documentDataId } = await putPdfFile({
name: 'Documenso Supporter Pledge.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(documentBuffer),

View File

@@ -21,6 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account.
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_allow_encrypted_documents: false,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.

View File

@@ -0,0 +1 @@
export const TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;

View File

@@ -110,7 +110,7 @@ export const resendDocument = async ({
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role,
@@ -135,7 +135,7 @@ export const resendDocument = async ({
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),

View File

@@ -14,7 +14,7 @@ import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
@@ -122,7 +122,7 @@ export const sealDocument = async ({
const { name, ext } = path.parse(document.title);
const { data: newData } = await putFile({
const { data: newData } = await putPdfFile({
name: `${name}_signed${ext}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),

View File

@@ -8,6 +8,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
@@ -20,7 +21,6 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -102,7 +102,7 @@ export const sendDocument = async ({
formValues: document.formValues as Record<string, string | number | boolean>,
});
const newDocumentData = await putFile({
const newDocumentData = await putPdfFile({
name: document.title,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@@ -151,7 +151,7 @@ export const sendDocument = async ({
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role,

View File

@@ -1,16 +1,35 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type RecipientWithId = {
id: number;
name?: string;
email: string;
};
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role'> & {
templateRecipientId: number;
fields: Field[];
};
export type CreateDocumentFromTemplateOptions = {
templateId: number;
userId: number;
teamId?: number;
recipients?: {
name?: string;
email: string;
role?: RecipientRole;
}[];
recipients:
| RecipientWithId[]
| {
name?: string;
email: string;
}[];
requestMetadata?: RequestMetadata;
};
export const createDocumentFromTemplate = async ({
@@ -18,7 +37,14 @@ export const createDocumentFromTemplate = async ({
userId,
teamId,
recipients,
requestMetadata,
}: CreateDocumentFromTemplateOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const template = await prisma.template.findUnique({
where: {
id: templateId,
@@ -39,8 +65,11 @@ export const createDocumentFromTemplate = async ({
}),
},
include: {
Recipient: true,
Field: true,
Recipient: {
include: {
Field: true,
},
},
templateDocumentData: true,
},
});
@@ -49,6 +78,43 @@ export const createDocumentFromTemplate = async ({
throw new Error('Template not found.');
}
if (recipients.length !== template.Recipient.length) {
throw new Error('Invalid number of recipients.');
}
let finalRecipients: FinalRecipient[] = [];
if (recipients.length > 0 && Object.prototype.hasOwnProperty.call(recipients[0], 'id')) {
finalRecipients = template.Recipient.map((templateRecipient) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const foundRecipient = (recipients as RecipientWithId[]).find(
(recipient) => recipient.id === templateRecipient.id,
);
if (!foundRecipient) {
throw new Error('Recipient not found.');
}
return {
templateRecipientId: templateRecipient.id,
fields: templateRecipient.Field,
name: foundRecipient.name ?? '',
email: foundRecipient.email,
role: templateRecipient.role,
};
});
} else {
// Backwards compatible logic for /v1/ API where we use the index to associate
// the provided recipient with the template recipient.
finalRecipients = recipients.map((recipient, index) => ({
templateRecipientId: template.Recipient[index].id,
fields: template.Recipient[index].Field,
name: recipient.name ?? '',
email: recipient.email,
role: template.Recipient[index].role,
}));
}
const documentData = await prisma.documentData.create({
data: {
type: template.templateDocumentData.type,
@@ -57,85 +123,82 @@ export const createDocumentFromTemplate = async ({
},
});
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
include: {
Recipient: {
orderBy: {
id: 'asc',
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {
createMany: {
data: finalRecipients.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
},
documentData: true,
},
});
include: {
Recipient: {
orderBy: {
id: 'asc',
},
},
documentData: true,
},
});
await prisma.field.createMany({
data: template.Field.map((field) => {
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.Recipient.find((recipient) => recipient.email === email);
if (!documentRecipient) {
if (!recipient) {
throw new Error('Recipient not found.');
}
return {
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: field.customText,
inserted: field.inserted,
fieldsToCreate = fieldsToCreate.concat(
fields.map((field) => ({
documentId: document.id,
recipientId: recipient.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
})),
);
});
await tx.field.createMany({
data: fieldsToCreate,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
recipientId: documentRecipient.id,
};
}),
});
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
user,
requestMetadata,
data: {
title: document.title,
},
}),
);
}
});
return document;
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: document,
userId,
teamId,
});
return document;
});
};

View File

@@ -1,9 +1,12 @@
import { base64 } from '@scure/base';
import { env } from 'next-runtime-env';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { DocumentDataType } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
type File = {
@@ -12,14 +15,38 @@ type File = {
arrayBuffer: () => Promise<ArrayBuffer>;
};
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
export const putPdfFile = async (file: File) => {
const isEncryptedDocumentsAllowed = await getFlag('app_allow_encrypted_documents').catch(
() => false,
);
// This will prevent uploading encrypted PDFs or anything that can't be opened.
if (!isEncryptedDocumentsAllowed) {
await PDFDocument.load(await file.arrayBuffer()).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
}
const { type, data } = await putFile(file);
return await createDocumentData({ type, data });
};
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file));
return await createDocumentData({ type, data });
};
const putFileInDatabase = async (file: File) => {

View File

@@ -10,7 +10,7 @@ import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/cons
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
@@ -86,7 +86,7 @@ export const singleplayerRouter = router({
},
});
const { id: documentDataId } = await putFile({
const { id: documentDataId } = await putPdfFile({
name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),

View File

@@ -1,10 +1,14 @@
import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError } from '@documenso/lib/errors/app-error';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Document } from '@documenso/prisma/client';
import { authenticatedProcedure, router } from '../trpc';
import {
@@ -49,19 +53,34 @@ export const templateRouter = router({
throw new Error('You have reached your document limit.');
}
return await createDocumentFromTemplate({
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
let document: Document = await createDocumentFromTemplate({
templateId,
teamId,
userId: ctx.user.id,
recipients: input.recipients,
requestMetadata,
});
if (input.sendDocument) {
document = await sendDocument({
documentId: document.id,
userId: ctx.user.id,
teamId,
requestMetadata,
}).catch((err) => {
console.error(err);
throw new AppError('DOCUMENT_SEND_FAILED');
});
}
return document;
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this document. Please try again later.',
});
throw AppError.parseErrorToTRPCError(err);
}
}),

View File

@@ -1,7 +1,5 @@
import { z } from 'zod';
import { RecipientRole } from '@documenso/prisma/client';
export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1).trim(),
teamId: z.number().optional(),
@@ -11,15 +9,14 @@ export const ZCreateTemplateMutationSchema = z.object({
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().optional(),
recipients: z
.array(
z.object({
email: z.string().email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string().optional(),
}),
),
sendDocument: z.boolean().optional(),
});
export const ZDuplicateTemplateMutationSchema = z.object({

View File

@@ -103,6 +103,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
appendSigner({
formId: nanoid(12),
name: `Recipient ${placeholderRecipientCount}`,
// Update TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX if this is ever changed.
email: `recipient.${placeholderRecipientCount}@documenso.com`,
role: RecipientRole.SIGNER,
});