diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index e6cbd6fd4..813458062 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -151,7 +151,7 @@ export const EditDocumentForm = ({ }; const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { - const { subject, message, timezone, dateFormat } = data.meta; + const { subject, message, timezone, dateFormat, redirectUrl } = data.meta; try { await sendDocument({ @@ -159,8 +159,9 @@ export const EditDocumentForm = ({ meta: { subject, message, - timezone, dateFormat, + timezone, + redirectUrl, }, }); diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index b7d2cf452..2bd888bb0 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -114,7 +114,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr Action - {recipient?.role !== RecipientRole.CC && ( + {recipient && recipient?.role !== RecipientRole.CC && ( {recipient?.role === RecipientRole.VIEWER && ( diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 7105baafd..7e6cf26b8 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -26,9 +26,10 @@ export type SigningFormProps = { document: Document; recipient: Recipient; fields: Field[]; + redirectUrl?: string | null; }; -export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => { +export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => { const router = useRouter(); const analytics = useAnalytics(); const { data: session } = useSession(); @@ -74,7 +75,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = timestamp: new Date().toISOString(), }); - router.push(`/sign/${recipient.token}/complete`); + redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`); }; return ( diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index 6e661e77a..44de2fc36 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -118,7 +118,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { ({recipient.email}) -
+
null), ]); - const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null); - if (!document || !document.documentData || !recipient) { return notFound(); } const truncatedTitle = truncateTitle(document.title); - const { documentData } = document; + const { documentData, documentMeta } = document; const { user } = await getServerComponentSession(); @@ -65,7 +62,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp document.status === DocumentStatus.COMPLETED || recipient.signingStatus === SigningStatus.SIGNED ) { - redirect(`/sign/${token}/complete`); + documentMeta?.redirectUrl + ? redirect(documentMeta.redirectUrl) + : redirect(`/sign/${token}/complete`); } if (documentMeta?.password) { @@ -133,7 +132,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
- +
diff --git a/package-lock.json b/package-lock.json index aae034c57..c4d4c1e5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14503,6 +14503,7 @@ "version": "6.9.7", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz", "integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==", + "peer": true, "engines": { "node": ">=6.0.0" } @@ -19495,14 +19496,14 @@ "@react-email/section": "0.0.10", "@react-email/tailwind": "0.0.9", "@react-email/text": "0.0.6", - "nodemailer": "^6.9.3", + "nodemailer": "^6.9.9", "react-email": "^1.9.5", "resend": "^2.0.0" }, "devDependencies": { "@documenso/tailwind-config": "*", "@documenso/tsconfig": "*", - "@types/nodemailer": "^6.4.8", + "@types/nodemailer": "^6.4.14", "tsup": "^7.1.0" } }, @@ -19520,6 +19521,14 @@ "node": ">=16.0.0" } }, + "packages/email/node_modules/nodemailer": { + "version": "6.9.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz", + "integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==", + "engines": { + "node": ">=6.0.0" + } + }, "packages/eslint-config": { "name": "@documenso/eslint-config", "version": "0.0.0", diff --git a/packages/email/package.json b/packages/email/package.json index d41a4c24c..984ea3d4c 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -35,14 +35,14 @@ "@react-email/section": "0.0.10", "@react-email/tailwind": "0.0.9", "@react-email/text": "0.0.6", - "nodemailer": "^6.9.3", + "nodemailer": "^6.9.9", "react-email": "^1.9.5", "resend": "^2.0.0" }, "devDependencies": { "@documenso/tailwind-config": "*", "@documenso/tsconfig": "*", - "@types/nodemailer": "^6.4.8", + "@types/nodemailer": "^6.4.14", "tsup": "^7.1.0" } } diff --git a/packages/lib/constants/url-regex.ts b/packages/lib/constants/url-regex.ts new file mode 100644 index 000000000..259ce070d --- /dev/null +++ b/packages/lib/constants/url-regex.ts @@ -0,0 +1,2 @@ +export const URL_REGEX = + /^(https?):\/\/(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i; diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 3e6cd75be..7bd6d93cc 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -9,6 +9,7 @@ export type CreateDocumentMetaOptions = { timezone?: string; password?: string; dateFormat?: string; + redirectUrl?: string; userId: number; }; @@ -20,6 +21,7 @@ export const upsertDocumentMeta = async ({ documentId, userId, password, + redirectUrl, }: CreateDocumentMetaOptions) => { await prisma.document.findFirstOrThrow({ where: { @@ -48,17 +50,19 @@ export const upsertDocumentMeta = async ({ create: { subject, message, + password, dateFormat, timezone, - password, documentId, + redirectUrl, }, update: { subject, message, - dateFormat, password, + dateFormat, timezone, + redirectUrl, }, }); }; diff --git a/packages/lib/server-only/document/duplicate-document-by-id.ts b/packages/lib/server-only/document/duplicate-document-by-id.ts index 5ca848bb3..4e6a7bd87 100644 --- a/packages/lib/server-only/document/duplicate-document-by-id.ts +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -39,6 +39,7 @@ export const duplicateDocumentById = async ({ dateFormat: true, password: true, timezone: true, + redirectUrl: true, }, }, }, diff --git a/packages/lib/server-only/document/get-document-by-token.ts b/packages/lib/server-only/document/get-document-by-token.ts index 62c8a5ca1..18f9a5161 100644 --- a/packages/lib/server-only/document/get-document-by-token.ts +++ b/packages/lib/server-only/document/get-document-by-token.ts @@ -27,6 +27,7 @@ export const getDocumentAndSenderByToken = async ({ include: { User: true, documentData: true, + documentMeta: true, }, }); diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index ecb45d461..71508a9c5 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -46,6 +46,10 @@ export const setFieldsForDocument = async ({ throw new Error('Document not found'); } + if (document.completedAt) { + throw new Error('Document already complete'); + } + const existingFields = await prisma.field.findMany({ where: { documentId, diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index d42d1d707..82261a446 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -44,6 +44,10 @@ export const setRecipientsForDocument = async ({ throw new Error('Document not found'); } + if (document.completedAt) { + throw new Error('Document already complete'); + } + const normalizedRecipients = recipients.map((recipient) => ({ ...recipient, email: recipient.email.toLowerCase(), @@ -77,8 +81,9 @@ export const setRecipientsForDocument = async ({ }) .filter((recipient) => { return ( - recipient._persisted?.sendStatus !== SendStatus.SENT && - recipient._persisted?.signingStatus !== SigningStatus.SIGNED + recipient._persisted?.role === RecipientRole.CC || + (recipient._persisted?.sendStatus !== SendStatus.SENT && + recipient._persisted?.signingStatus !== SigningStatus.SIGNED) ); }); @@ -96,6 +101,7 @@ export const setRecipientsForDocument = async ({ email: recipient.email, role: recipient.role, documentId, + sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, }, diff --git a/packages/prisma/migrations/20240206111230_add_document_meta_redirect_url/migration.sql b/packages/prisma/migrations/20240206111230_add_document_meta_redirect_url/migration.sql new file mode 100644 index 000000000..0eb8a1175 --- /dev/null +++ b/packages/prisma/migrations/20240206111230_add_document_meta_redirect_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "DocumentMeta" ADD COLUMN "redirectUrl" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index fc128efc1..ff2d12319 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -199,6 +199,7 @@ model DocumentMeta { dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text documentId Int @unique document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + redirectUrl String? } enum ReadStatus { diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 5940d971d..d0ff48941 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -215,13 +215,14 @@ export const documentRouter = router({ try { const { documentId, meta } = input; - if (meta.message || meta.subject || meta.timezone || meta.dateFormat) { + if (meta.message || meta.subject || meta.timezone || meta.dateFormat || meta.redirectUrl) { await upsertDocumentMeta({ documentId, subject: meta.subject, message: meta.message, dateFormat: meta.dateFormat, timezone: meta.timezone, + redirectUrl: meta.redirectUrl, userId: ctx.user.id, }); } diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index f8d008f50..fceb6413f 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { URL_REGEX } from '@documenso/lib/constants/url-regex'; import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; export const ZGetDocumentByIdQuerySchema = z.object({ @@ -73,6 +74,12 @@ export const ZSendDocumentMutationSchema = z.object({ message: z.string(), timezone: z.string(), dateFormat: z.string(), + redirectUrl: z + .string() + .optional() + .refine((value) => value === undefined || URL_REGEX.test(value), { + message: 'Please enter a valid URL', + }), }), }); diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 4230debe3..e0631d11a 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -307,6 +307,13 @@ export const AddFieldsFormPartial = ({ return recipientsByRole; }, [recipients]); + const recipientsByRoleToDisplay = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter( + ([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER, + ); + }, [recipientsByRole]); + return ( <> - {Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => ( + {recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
- { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName - } + {`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
{recipients.length === 0 && ( @@ -406,7 +410,7 @@ export const AddFieldsFormPartial = ({ {recipients.map((recipient) => ( { @@ -416,7 +420,7 @@ export const AddFieldsFormPartial = ({ > {recipient.name && ( diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 26aedcae7..b1341c6ca 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -105,7 +105,10 @@ export const AddSignersFormPartial = ({ } return recipients.some( - (recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT, + (recipient) => + recipient.id === id && + recipient.sendStatus === SendStatus.SENT && + recipient.role !== RecipientRole.CC, ); }; diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index b3bbcbe92..40e42e3b3 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Info } from 'lucide-react'; import { Controller, useForm } from 'react-hook-form'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; @@ -23,6 +25,7 @@ import { SelectTrigger, SelectValue, } from '@documenso/ui/primitives/select'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Combobox } from '../combobox'; import { FormErrorMessage } from '../form/form-error-message'; @@ -30,7 +33,7 @@ import { Input } from '../input'; import { Label } from '../label'; import { useStep } from '../stepper'; import { Textarea } from '../textarea'; -import type { TAddSubjectFormSchema } from './add-subject.types'; +import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types'; import { DocumentFlowFormContainerActions, DocumentFlowFormContainerContent, @@ -69,8 +72,10 @@ export const AddSubjectFormPartial = ({ message: document.documentMeta?.message ?? '', timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE, dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, + redirectUrl: document.documentMeta?.redirectUrl ?? '', }, }, + resolver: zodResolver(ZAddSubjectFormSchema), }); const onFormSubmit = handleSubmit(onSubmit); @@ -163,64 +168,94 @@ export const AddSubjectFormPartial = ({
- {hasDateField && ( - - - - Advanced Options - + + + + Advanced Options + - -
- + + {hasDateField && ( + <> +
+ - ( - + + + - - {DATE_FORMATS.map((format) => ( - - {format.label} - - ))} - - - )} - /> + + {DATE_FORMATS.map((format) => ( + + {format.label} + + ))} + + + )} + /> +
+ +
+ + + ( + value && onChange(value)} + disabled={documentHasBeenSent} + /> + )} + /> +
+ + )} + +
+
+
+ + + + + +
- -
- - - ( - value && onChange(value)} - disabled={documentHasBeenSent} - /> - )} - /> -
- - - - )} +
+
+ +
diff --git a/packages/ui/primitives/document-flow/add-subject.types.ts b/packages/ui/primitives/document-flow/add-subject.types.ts index ea14f4c0f..fd4175368 100644 --- a/packages/ui/primitives/document-flow/add-subject.types.ts +++ b/packages/ui/primitives/document-flow/add-subject.types.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { URL_REGEX } from '@documenso/lib/constants/url-regex'; export const ZAddSubjectFormSchema = z.object({ meta: z.object({ @@ -9,6 +10,12 @@ export const ZAddSubjectFormSchema = z.object({ message: z.string(), timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), + redirectUrl: z + .string() + .optional() + .refine((value) => value === undefined || URL_REGEX.test(value), { + message: 'Please enter a valid URL', + }), }), });