Compare commits

...

42 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
Lucas Smith
15dee5ef35 fix: enforce users to have stripe account (#1131)
## Description

Currently users who sign in via Google SSO do not get assigned a Stripe
customer account.

This enforces the Stripe customer requirement on sign in.

There might be a better place to put this so it's open to any
suggestions.
2024-05-01 16:48:05 +10:00
Lucas Smith
28d6f6e2e8 fix: improve sealing process (#1133)
## Description

Improves the sealing process by being strict on how long certificate
generation can take, opting to fail generation and continue sealing.

Also changes the ordering of sealing so an error in the process won't
also cause a document to be "COMPLETED" since it hasn't been
cryptographically sealed yet.

The downside to this change is that documents that fail during sealing
will require manual intervention as a signer or owner won't be able to
*complete* the document.

## Testing Performed

- Modified code to force specific failure modes to occur and verified
that documents were either gracefully sealed without a certificate or
not sealed and not completed.
2024-05-01 16:47:11 +10:00
Mythie
78dc57a6eb fix: improvements from review 2024-05-01 16:16:04 +10:00
Mythie
d3528f74f0 fix: improve sealing process
Improves the sealing process by being strict on how
long certificate generation can take, opting to fail
generation and continue sealing.

Also changes the ordering of sealing so an error in the
process won't also cause a document to be "COMPLETED"
since it hasn't been cryptographically sealed yet.

The downside to this change is that documents that fail
during sealing will require manual intervention as a signer
or owner won't be able to *complete* the document.
2024-05-01 14:18:01 +10:00
David Nguyen
dbd452be97 fix: delete pending documents (#1118)
## Description

Currently deleting a pending document where you are a recipient off will
delete the document, but will also throw an error.

This is due to the recipient being updated after the document deleted,
which is only supposed to happen for completed documents.
2024-04-30 20:53:18 +07:00
Adithya Krishna
5109bb17d6 chore: fix button styling (#1132)
**Description:**

This PR fixes the button styling issue

**Before:**


![image](https://github.com/documenso/documenso/assets/23498248/0af045aa-3714-48d8-9c22-6cd171b07079)

**After:**

<img width="1280" alt="Screenshot 2024-04-30 at 6 48 47 PM"
src="https://github.com/documenso/documenso/assets/23498248/e7dd99de-60fc-4cc2-aefc-21b130aa0116">
2024-04-30 18:57:29 +05:30
Adithya Krishna
6974a76ed4 chore: fix button styling 2024-04-30 18:47:49 +05:30
Adithya Krishna
5efb0894e6 chore: updated dark mode text (#1129)
Description:

This PR updates the dark mode text for this article,
https://app.documenso.com/articles/signature-disclosure
2024-04-30 17:14:28 +05:30
David Nguyen
cfec366c1a fix: refactor 2024-04-30 15:54:24 +07:00
David Nguyen
8622e68853 fix: add logging 2024-04-30 15:50:22 +07:00
Adithya Krishna
0e16a86e74 chore: updated dark mode text 2024-04-30 11:55:01 +05:30
David Nguyen
97d334a1da fix: force users to have a Stripe customer on sign in 2024-04-29 20:15:40 +07:00
Mythie
345e42537a fix: include all document meta when using the public api 2024-04-29 12:42:22 +10:00
Lucas Smith
8a24ca2065 fix: complete document when all recipients are CC (#1113)
## Description

Automatically marks the document as completed if all the recipients are
CC.

## Changes Made

Added an if statement in the last form step (`onAddSubjectFormSubmit`)
that checks if all the recipients are CC. If so, the document status is
updated to `COMPLETED`.

## Testing Performed

Tested the changes and they work as expected.

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced document sending logic to update document status based on
recipient roles.

- **Bug Fixes**
- Removed redundant form submission handling in the document editing
feature.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-27 21:39:25 +10:00
Lucas Smith
06dd8219a5 fix: increase trpc max duration (#1121)
## Description

Increase the max duration of the TRPC API endpoint to 120 seconds.
2024-04-27 21:38:21 +10:00
David Nguyen
74b9bc786b fix: extend 2024-04-27 18:29:52 +07:00
David Nguyen
364c499927 fix: increase trpc max duration 2024-04-27 15:21:46 +07:00
David Nguyen
b0ce06f6fe Merge branch 'main' into fix/doc-status-cc-role 2024-04-26 17:17:07 +07:00
David Nguyen
20edee7f1a fix: ssr feature flags (#1119)
## Description

Feature flags are broken on SSR due to this error

```
TypeError: fetch failed
    at Object.fetch (node:internal/deps/undici/undici:11731:11)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
  cause: RequestContentLengthMismatchError: Request body length does not match content-length header
      at write (node:internal/deps/undici/undici:8590:41)
      at _resume (node:internal/deps/undici/undici:8563:33)
      at resume (node:internal/deps/undici/undici:8459:7)
      at [dispatch] (node:internal/deps/undici/undici:7704:11)
      at Client.Intercept (node:internal/deps/undici/undici:7377:20)
      at Client.dispatch (node:internal/deps/undici/undici:6023:44)
      at [dispatch] (node:internal/deps/undici/undici:6254:32)
      at Pool.dispatch (node:internal/deps/undici/undici:6023:44)
      at [dispatch] (node:internal/deps/undici/undici:9343:27)
      at Agent.Intercept (node:internal/deps/undici/undici:7377:20) {
    code: 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'
  }
}
```

I've removed content-length header since it isn't mandatory to my
knowledge for get requests.

## Changes

- Add fallback local flags when individual flag request fails
- Add error logging
- Remove `content-length` from headers being passed to Posthog
2024-04-26 16:01:09 +07:00
David Nguyen
a25b9a372e Merge branch 'main' into fix/refactor-use-template 2024-04-26 13:25:19 +07:00
Lucas Smith
9bc5818d19 fix: use cdp and upgrade playwright again (#1117)
## Description

Upgrade playwright once again and use CDP for remote connections to
avoid version lock-in with `playwright.connect`

Resolves the issue where all CI is failing currently due to downgrading
playwright.
2024-04-26 15:56:55 +10:00
Mythie
481d739c37 chore: update package-lock 2024-04-26 13:25:16 +10:00
Mythie
88dedc9829 fix: use cdp and upgrade playwright again 2024-04-26 13:18:31 +10:00
Lucas Smith
4080806606 fix: minor updates 2024-04-26 02:17:56 +00:00
David Nguyen
39bd3e5880 fix: enhancements 2024-04-25 22:28:41 +07:00
David Nguyen
1e33bc2aa3 Merge branch 'main' into fix/doc-status-cc-role 2024-04-24 20:30:10 +07:00
Catalin Pit
d7959950e2 fix: edit-document line 2024-04-24 09:41:34 +03:00
Catalin Pit
bb43547a45 fix: complete document when all recipients are CC 2024-04-24 09:39:47 +03:00
Catalin Pit
3fb69422e8 Merge branch 'main' into fix/doc-status-cc-role 2024-04-23 14:26:37 +03:00
Catalin Pit
4d5365bddc fix: complete document when all recipients are CC 2024-04-23 14:24:58 +03:00
Catalin Pit
0eee570781 fix: complete document when all recipients are CC 2024-04-23 12:33:40 +03: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
29 changed files with 610 additions and 380 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

@@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
export default function SignatureDisclosure() {
return (
<div>
<article className="prose">
<article className="prose dark:prose-invert">
<h1>Electronic Signature Disclosure</h1>
<h2>Welcome</h2>

View File

@@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
@@ -18,15 +20,29 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
error: '/signin',
},
events: {
signIn: async ({ user }) => {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
});
signIn: async ({ user: { id: userId } }) => {
const [user] = await Promise.all([
await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
await prisma.userSecurityAuditLog.create({
data: {
userId,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
}),
]);
// Create the Stripe customer and attach it to the user if it doesn't exist.
if (user.customerId === null && IS_BILLING_ENABLED()) {
await getStripeCustomerByUser(user).catch((err) => {
console.error(err);
});
}
},
signOut: async ({ token }) => {
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;

View File

@@ -3,7 +3,7 @@ import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
export const config = {
maxDuration: 60,
maxDuration: 120,
api: {
bodyParser: {
sizeLimit: '50mb',

96
package-lock.json generated
View File

@@ -22,7 +22,7 @@
"eslint-config-custom": "*",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"playwright": "1.41.0",
"playwright": "1.43.0",
"prettier": "^2.5.1",
"rimraf": "^5.0.1",
"turbo": "^1.9.3"
@@ -4702,13 +4702,26 @@
"node": ">=14"
}
},
"node_modules/@playwright/browser-chromium": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz",
"integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.43.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@playwright/test": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
"integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==",
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz",
"integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==",
"dev": true,
"dependencies": {
"playwright": "1.40.0"
"playwright": "1.43.1"
},
"bin": {
"playwright": "cli.js"
@@ -4732,12 +4745,12 @@
}
},
"node_modules/@playwright/test/node_modules/playwright": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz",
"integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==",
"dev": true,
"dependencies": {
"playwright-core": "1.40.0"
"playwright-core": "1.43.1"
},
"bin": {
"playwright": "cli.js"
@@ -4750,9 +4763,9 @@
}
},
"node_modules/@playwright/test/node_modules/playwright-core": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz",
"integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
@@ -17660,11 +17673,11 @@
}
},
"node_modules/playwright": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0.tgz",
"integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==",
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
"integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
"dependencies": {
"playwright-core": "1.41.0"
"playwright-core": "1.43.0"
},
"bin": {
"playwright": "cli.js"
@@ -17676,6 +17689,17 @@
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
"integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -17689,17 +17713,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright/node_modules/playwright-core": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz",
"integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -24968,7 +24981,7 @@
"next-auth": "4.24.5",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
"playwright": "1.41.0",
"playwright": "1.43.0",
"react": "18.2.0",
"remeda": "^1.27.1",
"stripe": "^12.7.0",
@@ -24976,23 +24989,10 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@playwright/browser-chromium": "1.41.0",
"@playwright/browser-chromium": "1.43.0",
"@types/luxon": "^3.3.1"
}
},
"packages/lib/node_modules/@playwright/browser-chromium": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.41.0.tgz",
"integrity": "sha512-TaHfh3rDsz4+tVKdMMo4kdFOk8/4U6cPyMXHhoiJVmhOhjHXjR0qPMoa5gz5jDGl478cn5SoXmtgKPgTDFuS0g==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.41.0"
},
"engines": {
"node": ">=16"
}
},
"packages/lib/node_modules/nanoid": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
@@ -25010,18 +25010,6 @@
"node": "^14 || ^16 || >=18"
}
},
"packages/lib/node_modules/playwright-core": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz",
"integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"packages/prettier-config": {
"name": "@documenso/prettier-config",
"version": "0.0.0",

View File

@@ -38,7 +38,7 @@
"eslint-config-custom": "*",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"playwright": "1.41.0",
"playwright": "1.43.0",
"prettier": "^2.5.1",
"rimraf": "^5.0.1",
"turbo": "^1.9.3"

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,
@@ -229,6 +229,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
requestMetadata: extractNextApiRequestMetadata(args.req),
});
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const recipients = await setRecipientsForDocument({
userId: user.id,
teamId: team?.id,
@@ -296,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),
@@ -324,10 +331,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
subject: body.meta.subject,
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
}

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

@@ -39,7 +39,7 @@
"next-auth": "4.24.5",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
"playwright": "1.41.0",
"playwright": "1.43.0",
"react": "18.2.0",
"remeda": "^1.27.1",
"stripe": "^12.7.0",
@@ -48,6 +48,6 @@
},
"devDependencies": {
"@types/luxon": "^3.3.1",
"@playwright/browser-chromium": "1.41.0"
"@playwright/browser-chromium": "1.43.0"
}
}

View File

@@ -137,7 +137,7 @@ export const completeDocumentWithToken = async ({
await sendPendingEmail({ documentId, recipientId: recipient.id });
}
const documents = await prisma.document.updateMany({
const haveAllRecipientsSigned = await prisma.document.findFirst({
where: {
id: document.id,
Recipient: {
@@ -146,13 +146,9 @@ export const completeDocumentWithToken = async ({
},
},
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
if (documents.count > 0) {
if (haveAllRecipientsSigned) {
await sealDocument({ documentId: document.id, requestMetadata });
}

View File

@@ -75,18 +75,20 @@ export const deleteDocument = async ({
}
// Continue to hide the document from the user if they are a recipient.
// Dirty way of doing this but it's faster than refetching the document.
if (userRecipient?.documentDeletedAt === null) {
await prisma.recipient.update({
where: {
documentId_email: {
documentId: document.id,
email: user.email,
await prisma.recipient
.update({
where: {
id: userRecipient.id,
},
},
data: {
documentDeletedAt: new Date().toISOString(),
},
});
data: {
documentDeletedAt: new Date().toISOString(),
},
})
.catch(() => {
// Do nothing.
});
}
// Return partial document for API v1 response.

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';
@@ -40,6 +40,11 @@ export const sealDocument = async ({
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
},
include: {
documentData: true,
@@ -53,10 +58,6 @@ export const sealDocument = async ({
throw new Error(`Document ${document.id} has no document data`);
}
if (document.status !== DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has not been completed`);
}
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
@@ -92,9 +93,9 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
const certificate = await getCertificatePdf({ documentId }).then(async (doc) =>
PDFDocument.load(doc),
);
const certificate = await getCertificatePdf({ documentId })
.then(async (doc) => PDFDocument.load(doc))
.catch(() => null);
const doc = await PDFDocument.load(pdfData);
@@ -103,11 +104,13 @@ export const sealDocument = async ({
doc.getForm().flatten();
flattenAnnotations(doc);
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
if (certificate) {
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
certificatePages.forEach((page) => {
doc.addPage(page);
});
certificatePages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
await insertFieldInPDF(doc, field);
@@ -119,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),
@@ -138,6 +141,16 @@ export const sealDocument = async ({
}
await prisma.$transaction(async (tx) => {
await tx.document.update({
where: {
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentData.update({
where: {
id: documentData.id,

View File

@@ -4,8 +4,11 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
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';
@@ -18,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';
@@ -100,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),
@@ -149,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,
@@ -211,6 +213,31 @@ export const sendDocument = async ({
}),
);
const allRecipientsHaveNoActionToTake = document.Recipient.every(
(recipient) => recipient.role === RecipientRole.CC,
);
if (allRecipientsHaveNoActionToTake) {
const updatedDocument = await updateDocument({
documentId,
userId,
teamId,
data: { status: DocumentStatus.COMPLETED },
});
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
// Keep the return type the same for the `sendDocument` method
return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
Recipient: true,
},
});
}
const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({

View File

@@ -18,7 +18,9 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
let browser: Browser;
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
} else {
browser = await chromium.launch();
}
@@ -33,6 +35,7 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
const result = await page.pdf({

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

@@ -17,6 +17,7 @@ export const getFlag = async (
options?: GetFlagOptions,
): Promise<TFeatureFlagValue> => {
const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
@@ -25,7 +26,7 @@ export const getFlag = async (
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
const response = await fetch(url, {
return await fetch(url, {
headers: {
...requestHeaders,
},
@@ -35,9 +36,10 @@ export const getFlag = async (
})
.then(async (res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res))
.catch(() => false);
return response;
.catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS[flag] ?? false;
});
};
/**
@@ -50,6 +52,7 @@ export const getAllFlags = async (
options?: GetFlagOptions,
): Promise<Record<string, TFeatureFlagValue>> => {
const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS;
@@ -67,7 +70,10 @@ export const getAllFlags = async (
})
.then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS);
.catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
};
/**
@@ -89,7 +95,10 @@ export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFla
})
.then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS);
.catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
};
interface GetFlagOptions {

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,
});
@@ -282,6 +283,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={
isSubmitting || getValues('signers').some((signer) => signer.email === user?.email)
}