mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
11 Commits
eff7d90f43
...
fix/refact
| Author | SHA1 | Date | |
|---|---|---|---|
| bb17bb9800 | |||
| ec42fbcbcb | |||
| 91b4bb52b5 | |||
| 3209ce8c78 | |||
| 1e825ede45 | |||
| a25b9a372e | |||
| 39bd3e5880 | |||
| ef666b0e70 | |||
| 9a801d6091 | |||
| 193419d169 | |||
| 6cba74e128 |
@ -1,14 +1,16 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Plus } from 'lucide-react';
|
import { InfoIcon, Plus } from 'lucide-react';
|
||||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
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 type { Recipient } from '@documenso/prisma/client';
|
||||||
import { RecipientRole } from '@documenso/prisma/client';
|
|
||||||
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 { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@ -19,24 +21,59 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} 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 { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
|
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
const ZAddRecipientsForNewDocumentSchema = z.object({
|
const ZAddRecipientsForNewDocumentSchema = z
|
||||||
recipients: z.array(
|
.object({
|
||||||
z.object({
|
sendDocument: z.boolean(),
|
||||||
email: z.string().email(),
|
recipients: z.array(
|
||||||
name: z.string(),
|
z.object({
|
||||||
role: z.nativeEnum(RecipientRole),
|
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>;
|
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
|
||||||
|
|
||||||
@ -56,33 +93,31 @@ export function UseTemplateDialog({
|
|||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const {
|
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<TAddRecipientsForNewDocumentSchema>({
|
|
||||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
recipients:
|
sendDocument: false,
|
||||||
recipients.length > 0
|
recipients: recipients.map((recipient) => {
|
||||||
? recipients.map((recipient) => ({
|
const isRecipientPlaceholder = recipient.email.match(TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX);
|
||||||
nativeId: recipient.id,
|
|
||||||
formId: String(recipient.id),
|
if (isRecipientPlaceholder) {
|
||||||
name: recipient.name,
|
return {
|
||||||
email: recipient.email,
|
id: recipient.id,
|
||||||
role: recipient.role,
|
name: '',
|
||||||
}))
|
email: '',
|
||||||
: [
|
};
|
||||||
{
|
}
|
||||||
name: '',
|
|
||||||
email: '',
|
return {
|
||||||
role: RecipientRole.SIGNER,
|
id: recipient.id,
|
||||||
},
|
name: recipient.name,
|
||||||
],
|
email: recipient.email,
|
||||||
|
};
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
|
const { mutateAsync: createDocumentFromTemplate } =
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
|
||||||
@ -91,6 +126,7 @@ export function UseTemplateDialog({
|
|||||||
templateId,
|
templateId,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
recipients: data.recipients,
|
recipients: data.recipients,
|
||||||
|
sendDocument: data.sendDocument,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -101,18 +137,24 @@ export function UseTemplateDialog({
|
|||||||
|
|
||||||
router.push(`${documentRootPath}/${id}`);
|
router.push(`${documentRootPath}/${id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const toastPayload: Toast = {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'An error occurred while creating document from template.',
|
description: 'An error occurred while creating document from template.',
|
||||||
variant: 'destructive',
|
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({
|
const { fields: formRecipients } = useFieldArray({
|
||||||
control,
|
control: form.control,
|
||||||
name: 'recipients',
|
name: 'recipients',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -126,121 +168,110 @@ export function UseTemplateDialog({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Document Recipients</DialogTitle>
|
<DialogTitle>Create document from template</DialogTitle>
|
||||||
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
|
<DialogDescription>
|
||||||
|
{recipients.length === 0
|
||||||
|
? 'A draft document will be created'
|
||||||
|
: 'Add the recipients to create the document with'}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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
|
<Form {...form}>
|
||||||
control={control}
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
name={`recipients.${index}.email`}
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
render={({ field }) => (
|
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||||
<Input
|
{formRecipients.map((recipient, index) => (
|
||||||
id={`recipient-${recipient.id}-email`}
|
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
|
||||||
type="email"
|
<FormField
|
||||||
className="bg-background mt-2"
|
control={form.control}
|
||||||
disabled={isSubmitting}
|
name={`recipients.${index}.email`}
|
||||||
{...field}
|
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">
|
<FormField
|
||||||
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
|
control={form.control}
|
||||||
|
name={`recipients.${index}.name`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && <FormLabel>Name</FormLabel>}
|
||||||
|
|
||||||
<Controller
|
<FormControl>
|
||||||
control={control}
|
<Input {...field} placeholder={recipients[index].name} />
|
||||||
name={`recipients.${index}.name`}
|
</FormControl>
|
||||||
render={({ field }) => (
|
<FormMessage />
|
||||||
<Input
|
</FormItem>
|
||||||
id={`recipient-${recipient.id}-name`}
|
)}
|
||||||
type="text"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
{...field}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
/>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[60px]">
|
{recipients.length > 0 && (
|
||||||
<Controller
|
<div className="mt-4 flex flex-row items-center">
|
||||||
control={control}
|
<FormField
|
||||||
name={`recipients.${index}.role`}
|
control={form.control}
|
||||||
render={({ field: { value, onChange } }) => (
|
name="sendDocument"
|
||||||
<Select value={value} onValueChange={(x) => onChange(x)}>
|
render={({ field }) => (
|
||||||
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
|
<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">
|
<label
|
||||||
<SelectItem value={RecipientRole.SIGNER}>
|
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||||
<div className="flex items-center">
|
htmlFor="sendDocument"
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
>
|
||||||
Signer
|
Send document
|
||||||
</div>
|
<Tooltip>
|
||||||
</SelectItem>
|
<TooltipTrigger type="button">
|
||||||
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.CC}>
|
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||||
<div className="flex items-center">
|
<p>
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
The document will be immediately sent to recipients if this is
|
||||||
Receives copy
|
checked.
|
||||||
</div>
|
</p>
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.APPROVER}>
|
<p>Otherwise, the document will be created as a draft.</p>
|
||||||
<div className="flex items-center">
|
</TooltipContent>
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
</Tooltip>
|
||||||
Approver
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.VIEWER}>
|
<DialogFooter>
|
||||||
<div className="flex items-center">
|
<DialogClose asChild>
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
<Button type="button" variant="secondary">
|
||||||
Viewer
|
Close
|
||||||
</div>
|
</Button>
|
||||||
</SelectItem>
|
</DialogClose>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full">
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
|
{form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'}
|
||||||
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</fieldset>
|
||||||
))}
|
</form>
|
||||||
</div>
|
</Form>
|
||||||
|
|
||||||
<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>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -100,13 +100,21 @@ export type TCreateDocumentMutationResponseSchema = z.infer<
|
|||||||
|
|
||||||
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
recipients: z.array(
|
recipients: z.union([
|
||||||
z.object({
|
z.array(
|
||||||
name: z.string().min(1),
|
z.object({
|
||||||
email: z.string().email().min(1),
|
id: z.number(),
|
||||||
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
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
|
meta: z
|
||||||
.object({
|
.object({
|
||||||
subject: z.string(),
|
subject: z.string(),
|
||||||
|
|||||||
@ -189,7 +189,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
|||||||
|
|
||||||
// Use personal template.
|
// Use personal template.
|
||||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
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.waitForURL(/documents/);
|
||||||
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||||
await page.waitForURL('/documents');
|
await page.waitForURL('/documents');
|
||||||
@ -200,7 +207,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
|||||||
|
|
||||||
// Use team template.
|
// Use team template.
|
||||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
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.waitForURL(/\/t\/.+\/documents/);
|
||||||
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||||
await page.waitForURL(`/t/${team.url}/documents`);
|
await page.waitForURL(`/t/${team.url}/documents`);
|
||||||
|
|||||||
1
packages/lib/constants/template.ts
Normal file
1
packages/lib/constants/template.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
|
||||||
@ -1,16 +1,35 @@
|
|||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { prisma } from '@documenso/prisma';
|
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 = {
|
export type CreateDocumentFromTemplateOptions = {
|
||||||
templateId: number;
|
templateId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
recipients?: {
|
recipients:
|
||||||
name?: string;
|
| RecipientWithId[]
|
||||||
email: string;
|
| {
|
||||||
role?: RecipientRole;
|
name?: string;
|
||||||
}[];
|
email: string;
|
||||||
|
}[];
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createDocumentFromTemplate = async ({
|
export const createDocumentFromTemplate = async ({
|
||||||
@ -18,7 +37,14 @@ export const createDocumentFromTemplate = async ({
|
|||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
recipients,
|
recipients,
|
||||||
|
requestMetadata,
|
||||||
}: CreateDocumentFromTemplateOptions) => {
|
}: CreateDocumentFromTemplateOptions) => {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const template = await prisma.template.findUnique({
|
const template = await prisma.template.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: templateId,
|
id: templateId,
|
||||||
@ -39,8 +65,11 @@ export const createDocumentFromTemplate = async ({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
Recipient: true,
|
Recipient: {
|
||||||
Field: true,
|
include: {
|
||||||
|
Field: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
templateDocumentData: true,
|
templateDocumentData: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -49,6 +78,43 @@ export const createDocumentFromTemplate = async ({
|
|||||||
throw new Error('Template not found.');
|
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({
|
const documentData = await prisma.documentData.create({
|
||||||
data: {
|
data: {
|
||||||
type: template.templateDocumentData.type,
|
type: template.templateDocumentData.type,
|
||||||
@ -57,85 +123,82 @@ export const createDocumentFromTemplate = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const document = await prisma.document.create({
|
return await prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const document = await tx.document.create({
|
||||||
userId,
|
data: {
|
||||||
teamId: template.teamId,
|
userId,
|
||||||
title: template.title,
|
teamId: template.teamId,
|
||||||
documentDataId: documentData.id,
|
title: template.title,
|
||||||
Recipient: {
|
documentDataId: documentData.id,
|
||||||
create: template.Recipient.map((recipient) => ({
|
Recipient: {
|
||||||
email: recipient.email,
|
createMany: {
|
||||||
name: recipient.name,
|
data: finalRecipients.map((recipient) => ({
|
||||||
role: recipient.role,
|
email: recipient.email,
|
||||||
token: nanoid(),
|
name: recipient.name,
|
||||||
})),
|
role: recipient.role,
|
||||||
},
|
token: nanoid(),
|
||||||
},
|
})),
|
||||||
|
},
|
||||||
include: {
|
|
||||||
Recipient: {
|
|
||||||
orderBy: {
|
|
||||||
id: 'asc',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
documentData: true,
|
include: {
|
||||||
},
|
Recipient: {
|
||||||
});
|
orderBy: {
|
||||||
|
id: 'asc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentData: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.field.createMany({
|
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
|
||||||
data: template.Field.map((field) => {
|
|
||||||
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
|
|
||||||
|
|
||||||
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.');
|
throw new Error('Recipient not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
fieldsToCreate = fieldsToCreate.concat(
|
||||||
type: field.type,
|
fields.map((field) => ({
|
||||||
page: field.page,
|
documentId: document.id,
|
||||||
positionX: field.positionX,
|
recipientId: recipient.id,
|
||||||
positionY: field.positionY,
|
type: field.type,
|
||||||
width: field.width,
|
page: field.page,
|
||||||
height: field.height,
|
positionX: field.positionX,
|
||||||
customText: field.customText,
|
positionY: field.positionY,
|
||||||
inserted: field.inserted,
|
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,
|
documentId: document.id,
|
||||||
recipientId: documentRecipient.id,
|
user,
|
||||||
};
|
requestMetadata,
|
||||||
}),
|
data: {
|
||||||
});
|
title: document.title,
|
||||||
|
},
|
||||||
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(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return document;
|
await triggerWebhook({
|
||||||
|
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||||
|
data: document,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return document;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
import { getServerLimits } from '@documenso/ee/server-only/limits/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 { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||||
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
|
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
|
||||||
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
||||||
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-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 { authenticatedProcedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@ -49,19 +53,34 @@ export const templateRouter = router({
|
|||||||
throw new Error('You have reached your document limit.');
|
throw new Error('You have reached your document limit.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await createDocumentFromTemplate({
|
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
|
||||||
|
|
||||||
|
let document: Document = await createDocumentFromTemplate({
|
||||||
templateId,
|
templateId,
|
||||||
teamId,
|
teamId,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
recipients: input.recipients,
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
throw new TRPCError({
|
throw AppError.parseErrorToTRPCError(err);
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: 'We were unable to create this document. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { RecipientRole } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
export const ZCreateTemplateMutationSchema = z.object({
|
export const ZCreateTemplateMutationSchema = z.object({
|
||||||
title: z.string().min(1).trim(),
|
title: z.string().min(1).trim(),
|
||||||
teamId: z.number().optional(),
|
teamId: z.number().optional(),
|
||||||
@ -11,15 +9,14 @@ export const ZCreateTemplateMutationSchema = z.object({
|
|||||||
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||||
templateId: z.number(),
|
templateId: z.number(),
|
||||||
teamId: z.number().optional(),
|
teamId: z.number().optional(),
|
||||||
recipients: z
|
recipients: z.array(
|
||||||
.array(
|
z.object({
|
||||||
z.object({
|
id: z.number(),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
name: z.string(),
|
name: z.string().optional(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
}),
|
||||||
}),
|
),
|
||||||
)
|
sendDocument: z.boolean().optional(),
|
||||||
.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZDuplicateTemplateMutationSchema = z.object({
|
export const ZDuplicateTemplateMutationSchema = z.object({
|
||||||
|
|||||||
@ -103,6 +103,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
appendSigner({
|
appendSigner({
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
name: `Recipient ${placeholderRecipientCount}`,
|
name: `Recipient ${placeholderRecipientCount}`,
|
||||||
|
// Update TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX if this is ever changed.
|
||||||
email: `recipient.${placeholderRecipientCount}@documenso.com`,
|
email: `recipient.${placeholderRecipientCount}@documenso.com`,
|
||||||
role: RecipientRole.SIGNER,
|
role: RecipientRole.SIGNER,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user