From 37e9db6626a7336427b4a2de4e3825a64186f948 Mon Sep 17 00:00:00 2001 From: Prajwal Kulkarni Date: Tue, 6 Feb 2024 00:40:53 +0530 Subject: [PATCH 01/56] Remove document on go back click on step 1 Invoke onBackStep on "go back" click and conditionally render go back label --- packages/ui/primitives/document-flow/add-fields.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 74764df80..083fbdcbf 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -566,7 +566,9 @@ export const AddFieldsFormPartial = ({ onGoBackClick={() => { previousStep(); remove(); + documentFlow.onBackStep?.(); }} + goBackLabel={currentStep === 1 && typeof documentFlow.onBackStep === "function" ? "Remove" : undefined} onGoNextClick={() => void onFormSubmit()} /> From c08768a33038f3499e95f3125d453a087cbe42fa Mon Sep 17 00:00:00 2001 From: Prajwal Kulkarni Date: Tue, 6 Feb 2024 21:01:48 +0530 Subject: [PATCH 02/56] Format code with prettier --- packages/ui/primitives/document-flow/add-fields.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 083fbdcbf..0316f2c13 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -568,7 +568,11 @@ export const AddFieldsFormPartial = ({ remove(); documentFlow.onBackStep?.(); }} - goBackLabel={currentStep === 1 && typeof documentFlow.onBackStep === "function" ? "Remove" : undefined} + goBackLabel={ + currentStep === 1 && typeof documentFlow.onBackStep === 'function' + ? 'Remove' + : undefined + } onGoNextClick={() => void onFormSubmit()} /> From 4855882ae6ae4e4381cc79f97611e275ffce5cc5 Mon Sep 17 00:00:00 2001 From: Prajwal Kulkarni Date: Wed, 7 Feb 2024 21:31:51 +0530 Subject: [PATCH 03/56] Update label render condition --- .../src/app/(marketing)/singleplayer/client.tsx | 1 + packages/ui/primitives/document-flow/add-fields.tsx | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index a1b56257a..f9ca3bd9a 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -244,6 +244,7 @@ export const SinglePlayerClient = () => { recipients={uploadedFile ? [placeholderRecipient] : []} fields={fields} onSubmit={onFieldsSubmit} + isSinglePlayerMode={true} /> diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 0316f2c13..3e69c2e8d 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -53,6 +53,7 @@ export type AddFieldsFormProps = { recipients: Recipient[]; fields: Field[]; onSubmit: (_data: TAddFieldsFormSchema) => void; + isSinglePlayerMode?: boolean; }; export const AddFieldsFormPartial = ({ @@ -61,10 +62,12 @@ export const AddFieldsFormPartial = ({ recipients, fields, onSubmit, + isSinglePlayerMode = false, }: AddFieldsFormProps) => { const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); const { currentStep, totalSteps, previousStep } = useStep(); - + const canRenderBackButtonAsRemove = + currentStep === 1 && typeof documentFlow.onBackStep === 'function' && isSinglePlayerMode; const { control, handleSubmit, @@ -568,11 +571,7 @@ export const AddFieldsFormPartial = ({ remove(); documentFlow.onBackStep?.(); }} - goBackLabel={ - currentStep === 1 && typeof documentFlow.onBackStep === 'function' - ? 'Remove' - : undefined - } + goBackLabel={canRenderBackButtonAsRemove ? 'Remove' : undefined} onGoNextClick={() => void onFormSubmit()} /> From 897f0dabdefeebedb93f0a32be500d22d8c1fde0 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 15 Feb 2024 14:21:40 +0000 Subject: [PATCH 04/56] feat: 2fa pin input component --- apps/web/package.json | 2 + .../2fa/enable-authenticator-app-dialog.tsx | 44 +++++++++++++++---- package-lock.json | 32 ++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index fd4faa0c1..8656d5092 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", + "rci": "^0.1.0", "react": "18.2.0", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", @@ -47,6 +48,7 @@ "typescript": "5.2.2", "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", + "use-is-focused": "^0.0.1", "zod": "^3.22.4" }, "devDependencies": { diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index 7a181c4cc..7a493d5b0 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -27,8 +27,8 @@ import { FormLabel, FormMessage, } from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; import { PasswordInput } from '@documenso/ui/primitives/password-input'; +import { PinInput, type PinInputState } from '@documenso/ui/primitives/pin-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { RecoveryCodeList } from './recovery-code-list'; @@ -54,6 +54,7 @@ export const EnableAuthenticatorAppDialog = ({ open, onOpenChange, }: EnableAuthenticatorAppDialogProps) => { + const [state, setState] = useState('input'); const router = useRouter(); const { toast } = useToast(); @@ -119,13 +120,15 @@ export const EnableAuthenticatorAppDialog = ({ token, }: TEnableTwoFactorAuthenticationForm) => { try { - await enableTwoFactorAuthentication({ code: token }); + const enabled2fa = await enableTwoFactorAuthentication({ code: token }); toast({ title: 'Two-factor authentication enabled', description: 'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.', }); + + return enabled2fa; } catch (_err) { toast({ title: 'Unable to setup two-factor authentication', @@ -136,6 +139,31 @@ export const EnableAuthenticatorAppDialog = ({ } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const onPinInputChange = ({ currentTarget: input }: any) => { + input.value = input.value.replace(/\D+/g, ''); + + if (input.value.length === 6) { + setState('loading'); + + void onEnableTwoFactorAuthenticationFormSubmit({ token: input.value }).then((success) => { + if (success) { + setState('success'); + return; + } + + setState('error'); + + setTimeout(() => { + setState('input'); + input.value = ''; + input.dispatchEvent(new Event('input')); + input.focus(); + }, 500); + }); + } + }; + const onCompleteClick = () => { flushSync(() => { onOpenChange(false); @@ -146,7 +174,7 @@ export const EnableAuthenticatorAppDialog = ({ return ( - + Enable Authenticator App @@ -241,18 +269,18 @@ export const EnableAuthenticatorAppDialog = ({ ( + render={({ field: _field }) => ( Token - + )} /> - + {/* @@ -260,7 +288,7 @@ export const EnableAuthenticatorAppDialog = ({ - + */} )) diff --git a/package-lock.json b/package-lock.json index 3c136e801..803a68344 100644 --- a/package-lock.json +++ b/package-lock.json @@ -151,6 +151,7 @@ "perfect-freehand": "^1.2.0", "posthog-js": "^1.75.3", "posthog-node": "^3.1.1", + "rci": "^0.1.0", "react": "18.2.0", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", @@ -163,6 +164,7 @@ "typescript": "5.2.2", "ua-parser-js": "^1.0.37", "uqr": "^0.1.2", + "use-is-focused": "^0.0.1", "zod": "^3.22.4" }, "devDependencies": { @@ -15726,6 +15728,18 @@ "node": ">= 0.8" } }, + "node_modules/rci": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/rci/-/rci-0.1.0.tgz", + "integrity": "sha512-o/elFrXXRLdYDAq/qQUFE175TqzJ5nU3MYwIwa6WOZfljNJ4akQSy1n7zA79swB696MNIFDWJs+Do0q2FBTy+Q==", + "dependencies": { + "use-code-input": "0.0.2" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/re-resizable": { "version": "6.9.6", "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.9.6.tgz", @@ -18860,6 +18874,24 @@ } } }, + "node_modules/use-code-input": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/use-code-input/-/use-code-input-0.0.2.tgz", + "integrity": "sha512-lDIUiRca0K8sF+c/KZ9cz5g6oPqlFiTmaDgwGzg0wlNSnFAvROtweKy0XpihEWJwo2tjETtgAxIh82RVGaBFHQ==", + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/use-is-focused": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/use-is-focused/-/use-is-focused-0.0.1.tgz", + "integrity": "sha512-EXVmfDqdzUJOYukC9rBCs4TYd93lDVAL6TxegnV0+3U4cBxWxhbyt1bOm5u1ox+0MZZjamBFU/NSTLTtex2uwQ==", + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", From 345c4b8b147d418fb087d093d621b3935951d881 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 15 Feb 2024 15:55:58 +0000 Subject: [PATCH 05/56] feat: use pin-input on sign in --- .../2fa/enable-authenticator-app-dialog.tsx | 56 ++++++------ apps/web/src/components/forms/signin.tsx | 45 ++++++++-- packages/ui/primitives/pin-input.tsx | 85 +++++++++++++++++++ packages/ui/styles/theme.css | 22 +++++ 4 files changed, 176 insertions(+), 32 deletions(-) create mode 100644 packages/ui/primitives/pin-input.tsx diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index 7a493d5b0..fa5d223d8 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -139,31 +139,6 @@ export const EnableAuthenticatorAppDialog = ({ } }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const onPinInputChange = ({ currentTarget: input }: any) => { - input.value = input.value.replace(/\D+/g, ''); - - if (input.value.length === 6) { - setState('loading'); - - void onEnableTwoFactorAuthenticationFormSubmit({ token: input.value }).then((success) => { - if (success) { - setState('success'); - return; - } - - setState('error'); - - setTimeout(() => { - setState('input'); - input.value = ''; - input.dispatchEvent(new Event('input')); - input.focus(); - }, 500); - }); - } - }; - const onCompleteClick = () => { flushSync(() => { onOpenChange(false); @@ -273,7 +248,36 @@ export const EnableAuthenticatorAppDialog = ({ Token - + { + console.log(code); + + if (code.length === 6) { + setState('loading'); + + void onEnableTwoFactorAuthenticationFormSubmit({ token: code }).then( + (success) => { + if (success) { + setState('success'); + return; + } + + setState('error'); + + setTimeout(() => { + setState('input'); + input.value = ''; + input.dispatchEvent(new Event('input')); + input.focus(); + }, 500); + }, + ); + } + }} + autoFocus + /> diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index ec690a568..b182d5d76 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -31,6 +31,7 @@ import { } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { PasswordInput } from '@documenso/ui/primitives/password-input'; +import { PinInput, type PinInputState } from '@documenso/ui/primitives/pin-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; const ERROR_MESSAGES: Partial> = { @@ -72,6 +73,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< 'totp' | 'backup' >('totp'); + const [state, setState] = useState('input'); const form = useForm({ values: { @@ -151,18 +153,24 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign title: 'Unable to sign in', description: errorMessage ?? 'An unknown error occurred', }); - - return; } + setState('success'); + console.log(result); + if (!result?.url) { throw new Error('An unknown error occurred'); } window.location.href = result.url; } catch (err) { + form.setError('totpCode', { + message: 'invalid totp', + }); + toast({ title: 'An unknown error occurred', + variant: 'destructive', description: 'We encountered an unknown error while attempting to sign you In. Please try again later.', }); @@ -254,7 +262,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign open={isTwoFactorAuthenticationDialogOpen} onOpenChange={onCloseTwoFactorAuthenticationDialog} > - + Two-Factor Authentication @@ -265,13 +273,38 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign ( + render={({ field: _field }) => ( Authentication Token - + { + if (code.length === 6) { + setState('loading'); + form.setValue('totpCode', code); + + await form.handleSubmit(onFormSubmit)(); + + if (form.formState.isSubmitted && !form.formState.errors.totpCode) { + setState('success'); + return; + } + + setState('error'); + + setTimeout(() => { + setState('input'); + input.value = ''; + input.dispatchEvent(new Event('input')); + input.focus(); + }, 500); + } + }} + autoFocus + /> - )} /> diff --git a/packages/ui/primitives/pin-input.tsx b/packages/ui/primitives/pin-input.tsx new file mode 100644 index 000000000..b29d64376 --- /dev/null +++ b/packages/ui/primitives/pin-input.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useRef } from 'react'; + +import { CodeInput, getSegmentCssWidth } from 'rci'; +import { useIsFocused } from 'use-is-focused'; + +import { cn } from '@documenso/ui/lib/utils'; + +export type PinInputState = 'input' | 'loading' | 'error' | 'success'; +export type PinInputProps = { + id: string; + state: PinInputState; + autoFocus?: boolean; + onSubmit({ code, input }: { code: string; input: EventTarget & HTMLInputElement }): void; +}; + +const PinInput = ({ id, autoFocus, state, onSubmit }: PinInputProps) => { + const inputRef = useRef(null); + const focused = useIsFocused(inputRef); + + const width = getSegmentCssWidth('14px'); + + return ( + { + input.value = input.value.replace(/\D+/g, ''); + onSubmit({ code: input.value, input }); + }} + renderSegment={(segment) => { + const isCaret = focused && segment.state === 'cursor'; + const isSelection = focused && segment.state === 'selected'; + const isLoading = state === 'loading'; + const isSuccess = state === 'success'; + const isError = state === 'error'; + const isActive = isSuccess || isError || isSelection || isCaret; + + return ( +
+
+
+ ); + }} + /> + ); +}; + +export { PinInput }; diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index fe7bfa087..15c6a2f8d 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -114,3 +114,25 @@ .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgb(100 116 139 / 0.5); } + +@keyframes blink-caret { + 50% { + background: transparent; + } +} + +@keyframes shake { + 25% { + transform: translateX(10px); + } + 75% { + transform: translateX(-10px); + } +} + +@keyframes pulse-border { + 50% { + border-color: var(--segment-color); + box-shadow: var(--segment-color) 0 0 0 1px; + } +} From 94eee8b913b027cec90429123876c825283252ef Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Fri, 16 Feb 2024 20:49:52 +0000 Subject: [PATCH 06/56] chore: change font family --- packages/ui/primitives/pin-input.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/ui/primitives/pin-input.tsx b/packages/ui/primitives/pin-input.tsx index b29d64376..9f579288c 100644 --- a/packages/ui/primitives/pin-input.tsx +++ b/packages/ui/primitives/pin-input.tsx @@ -30,13 +30,12 @@ const PinInput = ({ id, autoFocus, state, onSubmit }: PinInputProps) => { inputClassName="caret-transparent selection:bg-transparent ring:ring-2" autoFocus={autoFocus} length={6} - fontFamily="Inter" - fontSize="36px" + fontSize="30px" readOnly={state !== 'input'} disabled={state === 'loading'} inputRef={inputRef} padding={'14px'} - spacing={'18px'} + spacing={'24px'} spellCheck={false} inputMode="numeric" pattern="[0-9]*" @@ -69,8 +68,7 @@ const PinInput = ({ id, autoFocus, state, onSubmit }: PinInputProps) => { >
Date: Fri, 16 Feb 2024 21:20:16 +0000 Subject: [PATCH 07/56] chore: use token input on enable 2fa --- .../2fa/enable-authenticator-app-dialog.tsx | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index fa5d223d8..41a5ca573 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -130,6 +130,10 @@ export const EnableAuthenticatorAppDialog = ({ return enabled2fa; } catch (_err) { + enableTwoFactorAuthenticationForm.setError('token', { + message: 'Unable to setup two-factor authentication', + }); + toast({ title: 'Unable to setup two-factor authentication', description: @@ -251,40 +255,41 @@ export const EnableAuthenticatorAppDialog = ({ { - console.log(code); - + onSubmit={async ({ code, input }) => { if (code.length === 6) { setState('loading'); + enableTwoFactorAuthenticationForm.setValue('token', code); - void onEnableTwoFactorAuthenticationFormSubmit({ token: code }).then( - (success) => { - if (success) { - setState('success'); - return; - } + await enableTwoFactorAuthenticationForm.handleSubmit( + onEnableTwoFactorAuthenticationFormSubmit, + )(); - setState('error'); + if ( + enableTwoFactorAuthenticationForm.formState.isSubmitted && + !enableTwoFactorAuthenticationForm.formState.errors.totpCode + ) { + setState('success'); + return; + } - setTimeout(() => { - setState('input'); - input.value = ''; - input.dispatchEvent(new Event('input')); - input.focus(); - }, 500); - }, - ); + setState('error'); + + setTimeout(() => { + setState('input'); + input.value = ''; + input.dispatchEvent(new Event('input')); + input.focus(); + }, 500); } }} autoFocus /> - )} /> - {/* + @@ -292,7 +297,7 @@ export const EnableAuthenticatorAppDialog = ({ - */} + )) From 5e8d93f24b7d6c90d058fc7f0d0c62dee9767d49 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 20 Mar 2024 15:42:17 +0200 Subject: [PATCH 08/56] feat: add kysely for raw type-safe SQL queries --- package-lock.json | 1508 ++++++++++++++++++++++++++++++++- packages/prisma/index.ts | 19 +- packages/prisma/package.json | 3 + packages/prisma/schema.prisma | 4 +- 4 files changed, 1529 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8d47958aa..49945a007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -188,6 +188,21 @@ "zod": "^3.20.0" } }, + "node_modules/@antfu/ni": { + "version": "0.21.8", + "resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-0.21.8.tgz", + "integrity": "sha512-90X8pU2szlvw0AJo9EZMbYc2eQKkmO7mAdC4tD4r5co2Mm56MT37MIG8EyB7p4WRheuzGxuLDxJ63mF6+Zajiw==", + "dev": true, + "bin": { + "na": "bin/na.mjs", + "nci": "bin/nci.mjs", + "ni": "bin/ni.mjs", + "nlx": "bin/nlx.mjs", + "nr": "bin/nr.mjs", + "nu": "bin/nu.mjs", + "nun": "bin/nun.mjs" + } + }, "node_modules/@aws-crypto/crc32": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", @@ -1408,6 +1423,39 @@ "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.0.0.tgz", "integrity": "sha512-GMu2OJiTd1HSe74bbJYQnVvELANpYiGFZELyyTM1CR0sdv5ReQAcJ/c/8pIrPab3lO11+D+EpuGLUxqz+y832g==" }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", + "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "dev": true, + "dependencies": { + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", + "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "dev": true, + "dependencies": { + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/types": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", + "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", + "dev": true + }, + "node_modules/@chevrotain/utils": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", + "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", + "dev": true + }, "node_modules/@commitlint/cli": { "version": "17.8.1", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-17.8.1.tgz", @@ -3485,6 +3533,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/@mrleebo/prisma-ast": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.7.0.tgz", + "integrity": "sha512-GTPkYf1meO2UXXIrz/SIDFWz+P4kXo2PTt36LYh/oNxV1PieYi7ZgenQk4IV0ut71Je3Z8ZoNZ8Tr7v2c1X1pg==", + "dev": true, + "dependencies": { + "chevrotain": "^10.5.0", + "lilconfig": "^2.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.1.1.tgz", @@ -4655,6 +4716,26 @@ } } }, + "node_modules/@prisma/debug": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.3.1.tgz", + "integrity": "sha512-eYrxqslEKf+wpMFIIHgbcNYuZBXUdiJLA85Or3TwOhgPIN1ZoXT9CwJph3ynW8H1Xg0LkdYLwVmuULCwiMoU5A==", + "dev": true, + "dependencies": { + "@types/debug": "4.1.8", + "debug": "4.3.4", + "strip-ansi": "6.0.1" + } + }, + "node_modules/@prisma/debug/node_modules/@types/debug": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", + "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==", + "dev": true, + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@prisma/engines": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.4.2.tgz", @@ -4666,6 +4747,358 @@ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574.tgz", "integrity": "sha512-wvupDL4AA1vf4TQNANg7kR7y98ITqPsk6aacfBxZKtrJKRIsWjURHkZCGcQliHdqCiW/hGreO6d6ZuSv9MhdAA==" }, + "node_modules/@prisma/fetch-engine": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.3.1.tgz", + "integrity": "sha512-w1yk1YiK8N82Pobdq58b85l6e8akyrkxuzwV9DoiUTRf3gpsuhJJesHc4Yi0WzUC9/3znizl1UfCsI6dhkj3Vw==", + "dev": true, + "dependencies": { + "@prisma/debug": "5.3.1", + "@prisma/get-platform": "5.3.1", + "execa": "5.1.1", + "find-cache-dir": "3.3.2", + "fs-extra": "11.1.1", + "hasha": "5.2.2", + "http-proxy-agent": "7.0.0", + "https-proxy-agent": "7.0.2", + "kleur": "4.1.5", + "node-fetch": "2.7.0", + "p-filter": "2.1.0", + "p-map": "4.0.0", + "p-retry": "4.6.2", + "progress": "2.0.3", + "rimraf": "3.0.2", + "temp-dir": "2.0.0", + "tempy": "1.0.1" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@prisma/fetch-engine/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@prisma/generator-helper": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-5.3.1.tgz", + "integrity": "sha512-zrYS0iHLgPlOJjYnd5KvVMMvSS+ktOL39EwooS5EnyvfzwfzxlKCeOUgxTfiKYs0WUWqzEvyNAYtramYgSknsQ==", + "dev": true, + "dependencies": { + "@prisma/debug": "5.3.1", + "@types/cross-spawn": "6.0.2", + "cross-spawn": "7.0.3", + "kleur": "4.1.5" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.3.1.tgz", + "integrity": "sha512-3IiZY2BUjKnAuZ0569zppZE6/rZbVAM09//c2nvPbbkGG9MqrirA8fbhhF7tfVmhyVfdmVCHnf/ujWPHJ8B46Q==", + "dev": true, + "dependencies": { + "@prisma/debug": "5.3.1", + "escape-string-regexp": "4.0.0", + "execa": "5.1.1", + "fs-jetpack": "5.1.0", + "kleur": "4.1.5", + "replace-string": "3.1.0", + "strip-ansi": "6.0.1", + "tempy": "1.0.1", + "terminal-link": "2.1.1", + "ts-pattern": "4.3.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/ts-pattern": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-4.3.0.tgz", + "integrity": "sha512-pefrkcd4lmIVR0LA49Imjf9DYLK8vtWhqBPA3Ya1ir8xCW0O2yjL9dsCVvI7pCodLC5q7smNpEtDR2yVulQxOg==", + "dev": true + }, + "node_modules/@prisma/internals": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@prisma/internals/-/internals-5.3.1.tgz", + "integrity": "sha512-zkW73hPHHNrMD21PeYgCTBfMu71vzJf+WtfydtJbS0JVJKyLfOel0iWSQg7wjNeQfccKp+NdHJ/5rTJ4NEUzgA==", + "dev": true, + "dependencies": { + "@antfu/ni": "0.21.8", + "@opentelemetry/api": "1.4.1", + "@prisma/debug": "5.3.1", + "@prisma/engines": "5.3.1", + "@prisma/fetch-engine": "5.3.1", + "@prisma/generator-helper": "5.3.1", + "@prisma/get-platform": "5.3.1", + "@prisma/prisma-schema-wasm": "5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59", + "archiver": "5.3.2", + "arg": "5.0.2", + "checkpoint-client": "1.1.27", + "cli-truncate": "2.1.0", + "dotenv": "16.0.3", + "escape-string-regexp": "4.0.0", + "execa": "5.1.1", + "find-up": "5.0.0", + "fp-ts": "2.16.1", + "fs-extra": "11.1.1", + "fs-jetpack": "5.1.0", + "global-dirs": "3.0.1", + "globby": "11.1.0", + "indent-string": "4.0.0", + "is-windows": "1.0.2", + "is-wsl": "2.2.0", + "kleur": "4.1.5", + "new-github-issue-url": "0.2.1", + "node-fetch": "2.7.0", + "npm-packlist": "5.1.3", + "open": "7.4.2", + "p-map": "4.0.0", + "prompts": "2.4.2", + "read-pkg-up": "7.0.1", + "replace-string": "3.1.0", + "resolve": "1.22.4", + "string-width": "4.2.3", + "strip-ansi": "6.0.1", + "strip-indent": "3.0.0", + "temp-dir": "2.0.0", + "tempy": "1.0.1", + "terminal-link": "2.1.1", + "tmp": "0.2.1", + "ts-pattern": "4.3.0" + } + }, + "node_modules/@prisma/internals/node_modules/@prisma/engines": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.3.1.tgz", + "integrity": "sha512-6QkILNyfeeN67BNEPEtkgh3Xo2tm6D7V+UhrkBbRHqKw9CTaz/vvTP/ROwYSP/3JT2MtIutZm/EnhxUiuOPVDA==", + "dev": true, + "hasInstallScript": true + }, + "node_modules/@prisma/internals/node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/@prisma/internals/node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@prisma/internals/node_modules/dotenv": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", + "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@prisma/internals/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/@prisma/internals/node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "dev": true, + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@prisma/internals/node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@prisma/internals/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@prisma/internals/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@prisma/internals/node_modules/resolve": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz", + "integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@prisma/internals/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@prisma/internals/node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@prisma/internals/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@prisma/internals/node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dev": true, + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/@prisma/internals/node_modules/ts-pattern": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-4.3.0.tgz", + "integrity": "sha512-pefrkcd4lmIVR0LA49Imjf9DYLK8vtWhqBPA3Ya1ir8xCW0O2yjL9dsCVvI7pCodLC5q7smNpEtDR2yVulQxOg==", + "dev": true + }, + "node_modules/@prisma/prisma-schema-wasm": { + "version": "5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59", + "resolved": "https://registry.npmjs.org/@prisma/prisma-schema-wasm/-/prisma-schema-wasm-5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59.tgz", + "integrity": "sha512-+zUI7NQDXfcNnU8HgrAj4jRMv8yRfITLzcfv0Urf0adKimM+hkkVG4rX38i9zWMlxekkEBw7NLFx3Gxxy8d3iQ==", + "dev": true + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -7814,6 +8247,15 @@ "@types/estree": "*" } }, + "node_modules/@types/cross-spawn": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz", + "integrity": "sha512-KuwNhp3eza+Rhu8IFI5HUXRP0LIhqH5cAjubUvGXXthh4YYBuP2ntwEX+Cz8GJoZUHlKo247wPWOfA9LYEq4cw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -8021,6 +8463,12 @@ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==" }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true + }, "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", @@ -8321,6 +8769,19 @@ "node": ">= 6.0.0" } }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -8397,6 +8858,81 @@ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", "optional": true }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -8630,6 +9166,15 @@ "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==" }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/astring": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/astring/-/astring-1.8.6.tgz", @@ -8638,6 +9183,12 @@ "astring": "bin/astring" } }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", + "dev": true + }, "node_modules/asynciterator.prototype": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz", @@ -8946,6 +9497,15 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -9217,6 +9777,99 @@ "node": ">= 0.8.0" } }, + "node_modules/checkpoint-client": { + "version": "1.1.27", + "resolved": "https://registry.npmjs.org/checkpoint-client/-/checkpoint-client-1.1.27.tgz", + "integrity": "sha512-xstymfUalJOv6ZvTtmkwP4ORJN36ikT4PvrIoLe3wstbYf87XIXCcZrSmbFQOjyB0v1qbBnCsAscDpfdZlCkFA==", + "dev": true, + "dependencies": { + "ci-info": "3.8.0", + "env-paths": "2.2.1", + "make-dir": "4.0.0", + "ms": "2.1.3", + "node-fetch": "2.6.12", + "uuid": "9.0.0" + } + }, + "node_modules/checkpoint-client/node_modules/ci-info": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", + "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/checkpoint-client/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/checkpoint-client/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/checkpoint-client/node_modules/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/checkpoint-client/node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/chevrotain": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", + "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "dev": true, + "dependencies": { + "@chevrotain/cst-dts-gen": "10.5.0", + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "@chevrotain/utils": "10.5.0", + "lodash": "4.17.21", + "regexp-to-ast": "0.5.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -9282,6 +9935,15 @@ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/cli-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", @@ -9817,6 +10479,12 @@ "minimist": "^1.1.0" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -9827,6 +10495,21 @@ "dot-prop": "^5.1.0" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -10046,6 +10729,31 @@ "typescript": ">=4" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -10070,6 +10778,15 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -10391,6 +11108,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/del": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", + "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", + "dev": true, + "dependencies": { + "globby": "^11.0.1", + "graceful-fs": "^4.2.4", + "is-glob": "^4.0.1", + "is-path-cwd": "^2.2.0", + "is-path-inside": "^3.0.2", + "p-map": "^4.0.0", + "rimraf": "^3.0.2", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -10861,6 +11615,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -12348,6 +13111,23 @@ "node": ">=8" } }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -12502,6 +13282,12 @@ "url": "https://ko-fi.com/tunnckoCore/commissions" } }, + "node_modules/fp-ts": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.1.tgz", + "integrity": "sha512-by7U5W8dkIzcvDofUcO42yl9JbnHTEDBrzu3pt5fKT+Z4Oy85I21K80EYJYdjQGC2qum4Vo55Ag57iiIK4FYuA==", + "dev": true + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -12542,6 +13328,12 @@ "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==" }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, "node_modules/fs-extra": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", @@ -12555,6 +13347,36 @@ "node": ">=14.14" } }, + "node_modules/fs-jetpack": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fs-jetpack/-/fs-jetpack-5.1.0.tgz", + "integrity": "sha512-Xn4fDhLydXkuzepZVsr02jakLlmoARPy+YWIclo4kh0GyNGUHnTqeH/w/qIsVn50dFxtp8otPL2t/HcPJBbxUA==", + "dev": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/fs-jetpack/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/fs-jetpack/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -13092,6 +13914,31 @@ "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.11.0.tgz", "integrity": "sha512-HVusNXlVqHe0fzIzdQOGolnFN6mX/fqcrSAOcTBXdvzrXVHwTz11vXeKRmkR5gTuwVpvHZEIyKoePDvuAR+XwQ==" }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasha/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", @@ -13380,6 +14227,31 @@ "node": ">= 0.6" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz", + "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -13454,6 +14326,39 @@ "node": ">= 4" } }, + "node_modules/ignore-walk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-5.0.1.tgz", + "integrity": "sha512-yemi4pMf51WKT7khInJqAvsIGzoqYXblnsz0ql8tM+yi1EKYTY1evX4NAbJrLL/Aanr2HyZeluqU+Oi7MGHokw==", + "dev": true, + "dependencies": { + "minimatch": "^5.0.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ignore-walk/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/ignore-walk/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/imagescript": { "version": "1.2.17", "resolved": "https://registry.npmjs.org/imagescript/-/imagescript-1.2.17.tgz", @@ -13882,6 +14787,15 @@ "node": ">=8" } }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -14075,6 +14989,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -14455,6 +15378,14 @@ "node": ">=6" } }, + "node_modules/kysely": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.3.tgz", + "integrity": "sha512-lG03Ru+XyOJFsjH3OMY6R/9U38IjDPfnOfDgO3ynhbDr+Dz8fak+X6L62vqu3iybQnj+lG84OttBuU9KY3L9kA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.22", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", @@ -14479,6 +15410,54 @@ "node": "> 0.8" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leac": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", @@ -14863,6 +15842,24 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true + }, "node_modules/lodash.isfunction": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", @@ -14910,6 +15907,12 @@ "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", "dev": true }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -15184,7 +16187,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "optional": true, + "devOptional": true, "dependencies": { "semver": "^6.0.0" }, @@ -15199,7 +16202,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "optional": true, + "devOptional": true, "bin": { "semver": "bin/semver.js" } @@ -16443,6 +17446,15 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, + "node_modules/new-github-issue-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/new-github-issue-url/-/new-github-issue-url-0.2.1.tgz", + "integrity": "sha512-md4cGoxuT4T4d/HDOXbrUHkTKrp/vp+m3aOA7XXVYwNsUNMK49g3SQicTSeV5GIz/5QVGAeYRAOlyp9OvlgsYA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/next": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/next/-/next-14.0.3.tgz", @@ -16702,6 +17714,85 @@ "node": ">=0.10.0" } }, + "node_modules/npm-bundled": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-2.0.1.tgz", + "integrity": "sha512-gZLxXdjEzE/+mOstGDqR6b0EkhJ+kM6fxM6vUuckuctuVPh80Q6pw/rSZj9s4Gex9GxWtIicO1pc8DB9KZWudw==", + "dev": true, + "dependencies": { + "npm-normalize-package-bin": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-2.0.0.tgz", + "integrity": "sha512-awzfKUO7v0FscrSpRoogyNm0sajikhBWpU0QMrW09AMi9n1PoKU6WaIqUzuJSQnpciZZmJ/jMZ2Egfmb/9LiWQ==", + "dev": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm-packlist": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-5.1.3.tgz", + "integrity": "sha512-263/0NGrn32YFYi4J533qzrQ/krmmrWwhKkzwTuM4f/07ug51odoaNjUexxO4vxlzURHcmYMH1QjvHjsNDKLVg==", + "dev": true, + "dependencies": { + "glob": "^8.0.1", + "ignore-walk": "^5.0.1", + "npm-bundled": "^2.0.0", + "npm-normalize-package-bin": "^2.0.0" + }, + "bin": { + "npm-packlist": "bin/index.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/npm-packlist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/npm-packlist/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm-packlist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -17031,6 +18122,27 @@ "@node-rs/bcrypt": "^1.7.3" } }, + "node_modules/p-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz", + "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==", + "dev": true, + "dependencies": { + "p-map": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-filter/node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -17059,6 +18171,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -17407,6 +18547,70 @@ "node": ">= 6" } }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/pkg-types": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", @@ -17815,6 +19019,30 @@ "node": ">=16.13" } }, + "node_modules/prisma-extension-kysely": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/prisma-extension-kysely/-/prisma-extension-kysely-2.1.0.tgz", + "integrity": "sha512-s1hujYjrNzfQc9Z79s93464mkTkkt9ZPqPELDRFe9jEmiIlM/JRXZtqIyyN3FM0GDkN/BDPVtpPtdC52XnjAcQ==", + "peerDependencies": { + "@prisma/client": "latest" + } + }, + "node_modules/prisma-kysely": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-1.8.0.tgz", + "integrity": "sha512-VpNpolZ8RXRgfU+j4R+fPZmX8EE95w3vJ2tt7+FwuiQc0leNTfLK5QLf3KbbPDes2rfjh3g20AjDxefQIo5GIA==", + "dev": true, + "dependencies": { + "@mrleebo/prisma-ast": "^0.7.0", + "@prisma/generator-helper": "5.3.1", + "@prisma/internals": "5.3.1", + "typescript": "^5.2.2", + "zod": "^3.22.2" + }, + "bin": { + "prisma-kysely": "dist/bin.js" + } + }, "node_modules/prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -17836,6 +19064,37 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -19110,6 +20369,36 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -19387,6 +20676,12 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "dev": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", @@ -19538,6 +20833,18 @@ "node": ">=0.10" } }, + "node_modules/replace-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/replace-string/-/replace-string-3.1.0.tgz", + "integrity": "sha512-yPpxc4ZR2makceA9hy/jHNqc7QVkd4Je/N0WRHm6bs3PtivPuPynxE5ejU/mp5EhnCv8+uZL7vhz8rkluSlx+Q==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -19653,6 +20960,15 @@ "node": ">=0.12" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -20123,6 +21439,12 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -20767,6 +22089,19 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -21038,6 +22373,105 @@ "node": ">=10" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-1.0.1.tgz", + "integrity": "sha512-biM9brNqxSc04Ee71hzFbryD11nX7VPhQQY32AdDmjFvodsRFz/3ufeoTZ6uYkRFfGo188tENcASNs3vTdsM0w==", + "dev": true, + "dependencies": { + "del": "^6.0.0", + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link/node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/text-extensions": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", @@ -21919,6 +23353,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/unist-util-generated": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-2.0.1.tgz", @@ -23171,6 +24617,61 @@ "resolved": "https://registry.npmjs.org/zenscroll/-/zenscroll-4.0.2.tgz", "integrity": "sha512-jEA1znR7b4C/NnaycInCU6h/d15ZzCd1jmsruqOKnZP6WXQSMH3W2GL+OXbkruslU4h+Tzuos0HdswzRUk/Vgg==" }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/zod": { "version": "3.22.4", "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", @@ -24754,12 +26255,15 @@ "license": "MIT", "dependencies": { "@prisma/client": "5.4.2", + "kysely": "^0.27.3", "prisma": "5.4.2", + "prisma-extension-kysely": "^2.1.0", "ts-pattern": "^5.0.6" }, "devDependencies": { "dotenv": "^16.3.1", "dotenv-cli": "^7.3.0", + "prisma-kysely": "^1.8.0", "ts-node": "^10.9.1", "typescript": "5.2.2" } diff --git a/packages/prisma/index.ts b/packages/prisma/index.ts index b9e290add..a9bbc2e65 100644 --- a/packages/prisma/index.ts +++ b/packages/prisma/index.ts @@ -1,5 +1,8 @@ import { PrismaClient } from '@prisma/client'; +import { Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely'; +import kyselyExtension from 'prisma-extension-kysely'; +import type { DB } from './generated/types.js'; import { getDatabaseUrl } from './helper'; declare global { @@ -12,10 +15,24 @@ if (!globalThis.prisma) { globalThis.prisma = new PrismaClient({ datasourceUrl: getDatabaseUrl() }); } -export const prisma = +const _prisma = globalThis.prisma || new PrismaClient({ datasourceUrl: getDatabaseUrl(), }); +export const prisma = _prisma.$extends( + kyselyExtension({ + kysely: (driver) => + new Kysely({ + dialect: { + createAdapter: () => new PostgresAdapter(), + createDriver: () => driver, + createIntrospector: (db) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler(), + }, + }), + }), +); + export const getPrismaClient = () => prisma; diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 199ce197a..76c6f0e0f 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -20,12 +20,15 @@ }, "dependencies": { "@prisma/client": "5.4.2", + "kysely": "^0.27.3", "prisma": "5.4.2", + "prisma-extension-kysely": "^2.1.0", "ts-pattern": "^5.0.6" }, "devDependencies": { "dotenv": "^16.3.1", "dotenv-cli": "^7.3.0", + "prisma-kysely": "^1.8.0", "ts-node": "^10.9.1", "typescript": "5.2.2" } diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index b1bf9f985..4acc8d70a 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1,5 +1,5 @@ -generator client { - provider = "prisma-client-js" +generator kysely { + provider = "prisma-kysely" } datasource db { From fdbac9fc03f91e2dba604ed0158044bec35837fa Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 21 Mar 2024 15:07:05 +0200 Subject: [PATCH 09/56] feat: update next-auth-options to use the kysely adapter --- package-lock.json | 332 ++++++++++++++++++++++++- packages/lib/next-auth/auth-options.ts | 17 +- packages/lib/package.json | 8 +- 3 files changed, 352 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49945a007..bd03a6627 100644 --- a/package-lock.json +++ b/package-lock.json @@ -203,6 +203,72 @@ "nun": "bin/nun.mjs" } }, + "node_modules/@auth/core": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.28.0.tgz", + "integrity": "sha512-/fh/tb/L4NMSYcyPoo4Imn8vN6MskcVfgESF8/ndgtI4fhD/7u7i5fTVzWgNRZ4ebIEGHNDbWFRxaTu1NtQgvA==", + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.4.0", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@auth/core/node_modules/jose": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.3.tgz", + "integrity": "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/core/node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@auth/core/node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/@aws-crypto/crc32": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", @@ -8247,6 +8313,11 @@ "@types/estree": "*" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, "node_modules/@types/cross-spawn": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz", @@ -8426,6 +8497,74 @@ "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-6.0.3.tgz", "integrity": "sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==" }, + "node_modules/@types/pg": { + "version": "8.11.4", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.4.tgz", + "integrity": "sha512-yw3Bwbda6vO+NvI1Ue/YKOwtl31AYvvd/e73O3V4ZkNzuGpTDndLSyc0dQRB2xrQqDePd20pEGIfqSp/GH3pRw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, + "node_modules/@types/pg/node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dev": true, + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/pg/node_modules/postgres-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/pg/node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -9519,6 +9658,14 @@ "node": ">=0.10" } }, + "node_modules/buffer-writer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", + "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==", + "engines": { + "node": ">=4" + } + }, "node_modules/buffers": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", @@ -17830,6 +17977,14 @@ "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" }, + "node_modules/oauth4webapi": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.10.3.tgz", + "integrity": "sha512-9FkXEXfzVKzH63GUOZz1zMr3wBaICSzk6DLXx+CGdrQ10ItNk2ePWzYYc1fdmKq1ayGFb2aX97sRCoZ2s0mkDw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -17947,6 +18102,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, "node_modules/oidc-token-hash": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", @@ -18218,6 +18379,11 @@ "pjv": "bin/pjv" } }, + "node_modules/packet-reader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", + "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -18503,6 +18669,106 @@ "is-reference": "^3.0.0" } }, + "node_modules/pg": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", + "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", + "dependencies": { + "buffer-writer": "2.0.0", + "packet-reader": "1.0.0", + "pg-connection-string": "^2.6.2", + "pg-pool": "^3.6.1", + "pg-protocol": "^1.6.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", + "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-pool": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.1.tgz", + "integrity": "sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz", + "integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pgpass/node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -18825,6 +19091,47 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-range": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", + "dev": true + }, "node_modules/posthog-js": { "version": "1.93.2", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.93.2.tgz", @@ -26187,6 +26494,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@auth/kysely-adapter": "^0.6.0", "@aws-sdk/client-s3": "^3.410.0", "@aws-sdk/cloudfront-signer": "^3.410.0", "@aws-sdk/s3-request-presigner": "^3.410.0", @@ -26204,12 +26512,14 @@ "@sindresorhus/slugify": "^2.2.1", "@upstash/redis": "^1.20.6", "@vvo/tzdb": "^6.117.0", + "kysely": "^0.26.3", "luxon": "^3.4.0", "nanoid": "^4.0.2", "next": "14.0.3", "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", + "pg": "^8.11.3", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -26217,7 +26527,27 @@ "zod": "^3.22.4" }, "devDependencies": { - "@types/luxon": "^3.3.1" + "@types/luxon": "^3.3.1", + "@types/pg": "^8.11.4" + } + }, + "packages/lib/node_modules/@auth/kysely-adapter": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@auth/kysely-adapter/-/kysely-adapter-0.6.0.tgz", + "integrity": "sha512-WTWkaoS4cD7s5p/IsB/6cgJcC+qvEo+D/ix7l9YHajj0M4ZgJMKuqgWDER+gllc761jxAYfLizJ9d9DjgFFTPA==", + "dependencies": { + "@auth/core": "0.28.0" + }, + "peerDependencies": { + "kysely": "^0.26.1" + } + }, + "packages/lib/node_modules/kysely": { + "version": "0.26.3", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.26.3.tgz", + "integrity": "sha512-yWSgGi9bY13b/W06DD2OCDDHQmq1kwTGYlQ4wpZkMOJqMGCstVCFIvxCCVG4KfY1/3G0MhDAcZsip/Lw8/vJWw==", + "engines": { + "node": ">=14.0.0" } }, "packages/lib/node_modules/nanoid": { diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 74a712576..acf614051 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -1,6 +1,7 @@ /// -import { PrismaAdapter } from '@next-auth/prisma-adapter'; +import { KyselyAdapter } from '@auth/kysely-adapter'; import { compare } from '@node-rs/bcrypt'; +import { Kysely, PostgresDialect } from 'kysely'; import { DateTime } from 'luxon'; import type { AuthOptions, Session, User } from 'next-auth'; import type { JWT } from 'next-auth/jwt'; @@ -8,9 +9,11 @@ import CredentialsProvider from 'next-auth/providers/credentials'; import type { GoogleProfile } from 'next-auth/providers/google'; import GoogleProvider from 'next-auth/providers/google'; import { env } from 'next-runtime-env'; +import { Pool } from 'pg'; import { prisma } from '@documenso/prisma'; import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client'; +import type { DB } from '@documenso/prisma/generated/types.js'; import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; @@ -20,8 +23,18 @@ import { sendConfirmationToken } from '../server-only/user/send-confirmation-tok import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata'; import { ErrorCode } from './error-codes'; +// move this from here +const db = new Kysely({ + dialect: new PostgresDialect({ + pool: new Pool({ + connectionString: process.env.DATABASE_URL, + }), + }), +}); + export const NEXT_AUTH_OPTIONS: AuthOptions = { - adapter: PrismaAdapter(prisma), + //@ts-expect-error - https://github.com/nextauthjs/next-auth/issues/8660 + adapter: KyselyAdapter(db), secret: process.env.NEXTAUTH_SECRET ?? 'secret', session: { strategy: 'jwt', diff --git a/packages/lib/package.json b/packages/lib/package.json index 7a32b3058..df122ef29 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -16,6 +16,7 @@ "clean": "rimraf node_modules" }, "dependencies": { + "@auth/kysely-adapter": "^0.6.0", "@aws-sdk/client-s3": "^3.410.0", "@aws-sdk/cloudfront-signer": "^3.410.0", "@aws-sdk/s3-request-presigner": "^3.410.0", @@ -27,18 +28,20 @@ "@next-auth/prisma-adapter": "1.0.7", "@noble/ciphers": "0.4.0", "@noble/hashes": "1.3.2", + "@node-rs/bcrypt": "^1.10.0", "@pdf-lib/fontkit": "^1.1.1", "@scure/base": "^1.1.3", "@sindresorhus/slugify": "^2.2.1", "@upstash/redis": "^1.20.6", "@vvo/tzdb": "^6.117.0", - "@node-rs/bcrypt": "^1.10.0", + "kysely": "^0.26.3", "luxon": "^3.4.0", "nanoid": "^4.0.2", "next": "14.0.3", "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", + "pg": "^8.11.3", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -46,6 +49,7 @@ "zod": "^3.22.4" }, "devDependencies": { - "@types/luxon": "^3.3.1" + "@types/luxon": "^3.3.1", + "@types/pg": "^8.11.4" } } From 6b73899ecc19e7f7f8ece9a404d2a5662ce490de Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 21 Mar 2024 15:46:53 +0200 Subject: [PATCH 10/56] chore: re-arrange stuff --- packages/lib/next-auth/auth-options.ts | 13 +------------ packages/lib/next-auth/kysely-db/db.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 12 deletions(-) create mode 100644 packages/lib/next-auth/kysely-db/db.ts diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index acf614051..f04de55f8 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -1,7 +1,6 @@ /// import { KyselyAdapter } from '@auth/kysely-adapter'; import { compare } from '@node-rs/bcrypt'; -import { Kysely, PostgresDialect } from 'kysely'; import { DateTime } from 'luxon'; import type { AuthOptions, Session, User } from 'next-auth'; import type { JWT } from 'next-auth/jwt'; @@ -9,11 +8,9 @@ import CredentialsProvider from 'next-auth/providers/credentials'; import type { GoogleProfile } from 'next-auth/providers/google'; import GoogleProvider from 'next-auth/providers/google'; import { env } from 'next-runtime-env'; -import { Pool } from 'pg'; import { prisma } from '@documenso/prisma'; import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client'; -import type { DB } from '@documenso/prisma/generated/types.js'; import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; @@ -22,15 +19,7 @@ import { getUserByEmail } from '../server-only/user/get-user-by-email'; import { sendConfirmationToken } from '../server-only/user/send-confirmation-token'; import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata'; import { ErrorCode } from './error-codes'; - -// move this from here -const db = new Kysely({ - dialect: new PostgresDialect({ - pool: new Pool({ - connectionString: process.env.DATABASE_URL, - }), - }), -}); +import { db } from './kysely-db/db'; export const NEXT_AUTH_OPTIONS: AuthOptions = { //@ts-expect-error - https://github.com/nextauthjs/next-auth/issues/8660 diff --git a/packages/lib/next-auth/kysely-db/db.ts b/packages/lib/next-auth/kysely-db/db.ts new file mode 100644 index 000000000..6f6f78ef9 --- /dev/null +++ b/packages/lib/next-auth/kysely-db/db.ts @@ -0,0 +1,14 @@ +import { KyselyAuth } from '@auth/kysely-adapter'; +import type { Codegen } from '@auth/kysely-adapter'; +import { PostgresDialect } from 'kysely'; +import { Pool } from 'pg'; + +import type { DB } from '@documenso/prisma/generated/types'; + +export const db = new KyselyAuth({ + dialect: new PostgresDialect({ + pool: new Pool({ + connectionString: process.env.DATABASE_URL, + }), + }), +}); From 462e1348a8b39cf5adbaa7e58900cdcf7d194a30 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 28 Mar 2024 12:02:51 +0200 Subject: [PATCH 11/56] chore: test queries --- .../server-only/document/find-documents.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index f34cc4c2c..6ddafd97f 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -1,3 +1,4 @@ +import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { DateTime } from 'luxon'; import { P, match } from 'ts-pattern'; @@ -60,6 +61,22 @@ export const findDocuments = async ({ teamEmail: true, }, }); + + const teamQuery = await prisma.$kysely + .selectFrom('Team') + .selectAll('Team') + .where('Team.id', '=', teamId) + .select((eb) => [ + jsonObjectFrom( + eb + .selectFrom('TeamEmail') + .selectAll('TeamEmail') + .where('TeamEmail.teamId', '=', teamId), + ).as('teamEmail'), + ]) + .innerJoin('TeamMember', 'TeamMember.teamId', 'Team.id') + .where('TeamMember.userId', '=', userId) + .executeTakeFirstOrThrow(); } return { @@ -128,6 +145,45 @@ export const findDocuments = async ({ }; } + const dataQuery = await prisma.$kysely + .selectFrom('Document') + .selectAll('Document') + .select((eb) => [ + jsonObjectFrom( + eb + .selectFrom('User') + .select(['id', 'name', 'email']) + .whereRef('User.id', '=', 'Document.userId'), + ).as('User'), + jsonObjectFrom( + eb + .selectFrom('Recipient') + .selectAll('Recipient') + .whereRef('Recipient.documentId', '=', 'Document.id'), + ).as('Recipient'), + jsonObjectFrom( + eb.selectFrom('Team').select(['id', 'url']).whereRef('Team.id', '=', 'Document.teamId'), + ).as('team'), + ]) + .where(({ eb, or, and, not, exists, selectFrom }) => + and([ + eb('Document.title', 'ilike', `${term}`), + or([ + eb('Document.status', '=', 'COMPLETED'), + and([ + not(eb('Document.status', 'ilike', 'COMPLETED')), + eb('Document.deletedAt', '=', null), + ]), + ]), + ]), + ) + .offset(Math.max(page - 1, 0) * perPage) + .limit(perPage) + .orderBy(orderByColumn, orderByDirection) + .execute(); + + console.log('dataQuery', dataQuery); + const [data, count] = await Promise.all([ prisma.document.findMany({ where: whereClause, From f520e0a7a6bb6e364cff73bde62f2ba131a96466 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 29 Mar 2024 17:23:42 +0200 Subject: [PATCH 12/56] chore: converting to kysely --- .../server-only/document/find-documents.ts | 174 ++++++++++++++++-- 1 file changed, 156 insertions(+), 18 deletions(-) diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 6ddafd97f..d5d0b8e7b 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -1,4 +1,5 @@ -import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { sql } from 'kysely'; +import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { DateTime } from 'luxon'; import { P, match } from 'ts-pattern'; @@ -76,7 +77,7 @@ export const findDocuments = async ({ ]) .innerJoin('TeamMember', 'TeamMember.teamId', 'Team.id') .where('TeamMember.userId', '=', userId) - .executeTakeFirstOrThrow(); + .execute(); } return { @@ -145,7 +146,7 @@ export const findDocuments = async ({ }; } - const dataQuery = await prisma.$kysely + let dataQuery = prisma.$kysely .selectFrom('Document') .selectAll('Document') .select((eb) => [ @@ -155,7 +156,7 @@ export const findDocuments = async ({ .select(['id', 'name', 'email']) .whereRef('User.id', '=', 'Document.userId'), ).as('User'), - jsonObjectFrom( + jsonArrayFrom( eb .selectFrom('Recipient') .selectAll('Recipient') @@ -164,25 +165,160 @@ export const findDocuments = async ({ jsonObjectFrom( eb.selectFrom('Team').select(['id', 'url']).whereRef('Team.id', '=', 'Document.teamId'), ).as('team'), - ]) - .where(({ eb, or, and, not, exists, selectFrom }) => - and([ - eb('Document.title', 'ilike', `${term}`), - or([ - eb('Document.status', '=', 'COMPLETED'), + ]); + + if (term && term.length >= 1) { + dataQuery = dataQuery.where('Document.title', 'ilike', `%${term}%`); + } + + if (period) { + const daysAgo = parseInt(period.replace(/d$/, ''), 10); + const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + dataQuery = dataQuery.where('Document.createdAt', '>=', startOfPeriod.toJSDate()); + } + + if (senderIds && senderIds.length > 0) { + dataQuery = dataQuery.where('Document.userId', 'in', senderIds); + } + + if (team) { + console.log('team'); + } else if (user) { + if (ExtendedDocumentStatus.ALL) { + console.log('inside EXTENDED_DOCUMENT_STATUS.ALL'); + dataQuery = dataQuery.where(({ eb, or, and, exists }) => { + return or([ + eb('Document.userId', '=', user.id), + eb('Document.teamId', '=', null), and([ - not(eb('Document.status', 'ilike', 'COMPLETED')), - eb('Document.deletedAt', '=', null), + eb(sql`"Document"."status"::text`, '=', sql`${ExtendedDocumentStatus.COMPLETED}`), + exists( + eb + .selectFrom('Recipient') + .selectAll('Recipient') + .whereRef('Recipient.documentId', '=', 'Document.id') + .where('Recipient.email', '=', user.email), + ), ]), - ]), - ]), - ) + and([ + eb(sql`"Document"."status"::text`, '=', sql`${ExtendedDocumentStatus.PENDING}`), + exists( + eb + .selectFrom('Recipient') + .selectAll('Recipient') + .whereRef('Recipient.documentId', '=', 'Document.id') + .where('Recipient.email', '=', user.email), + ), + ]), + ]); + }); + } + // } else if (ExtendedDocumentStatus.INBOX) { + // dataQuery = dataQuery.where(({ eb, and, not, exists }) => { + // return and([ + // not(eb(sql`status::text`, '=', ExtendedDocumentStatus.DRAFT)), + // exists( + // eb + // .selectFrom('Recipient') + // .selectAll('Recipient') + // .whereRef('Recipient.documentId', '=', 'Document.id') + // .where('Recipient.email', '=', user.email) + // .where(sql`Recipient.signingStatus::text`, '=', SigningStatus.NOT_SIGNED) + // .where('Recipient.role', '<>', RecipientRole.CC), + // ), + // ]); + // }); + // } else if (ExtendedDocumentStatus.DRAFT) { + // dataQuery = dataQuery.where(({ eb, and }) => { + // return and([ + // eb('Document.userId', '=', user.id), + // eb('Document.teamId', '=', null), + // eb(sql`status::text`, '=', ExtendedDocumentStatus.DRAFT), + // ]); + // }); + // } else if (ExtendedDocumentStatus.PENDING) { + // dataQuery = dataQuery.where(({ eb, or, and, exists }) => { + // return or([ + // and([ + // eb('Document.userId', '=', user.id), + // eb('Document.teamId', '=', null), + // eb(sql`status::text`, '=', ExtendedDocumentStatus.PENDING), + // ]), + // and([ + // eb(sql`status::text`, '=', ExtendedDocumentStatus.PENDING), + // exists( + // eb + // .selectFrom('Recipient') + // .selectAll('Recipient') + // .whereRef('Recipient.documentId', '=', 'Document.id') + // .where('Recipient.email', '=', user.email) + // .where(sql`Recipient.signingStatus::text`, '=', SigningStatus.SIGNED) + // .where('Recipient.role', '<>', RecipientRole.CC), + // ), + // ]), + // ]); + // }); + // } else if (ExtendedDocumentStatus.COMPLETED) { + // dataQuery = dataQuery.where(({ eb, or, exists, and }) => { + // return or([ + // and([ + // eb('Document.userId', '=', user.id), + // eb('Document.teamId', '=', null), + // eb(sql`status::text`, '=', ExtendedDocumentStatus.COMPLETED), + // ]), + // and([ + // eb(sql`status::text`, '=', ExtendedDocumentStatus.COMPLETED), + // exists( + // eb + // .selectFrom('Recipient') + // .selectAll('Recipient') + // .whereRef('Recipient.documentId', '=', 'Document.id') + // .where('Recipient.email', '=', user.email), + // ), + // ]), + // ]); + // }); + // } + } else { + return { + data: [], + count: 0, + currentPage: 1, + perPage, + totalPages: 0, + }; + } + + // dataQuery = dataQuery.where(({ eb, or, and }) => + // and([ + // or([ + // eb(sql`"Document"."status"::text`, '=', sql`${ExtendedDocumentStatus.COMPLETED}`), + // and([ + // eb(sql`"Document"."status"::text`, '<>', sql`${ExtendedDocumentStatus.COMPLETED}`), + // eb('Document.deletedAt', '=', null), + // ]), + // ]), + // ]), + // ); + + const finalQuery = dataQuery .offset(Math.max(page - 1, 0) * perPage) .limit(perPage) - .orderBy(orderByColumn, orderByDirection) - .execute(); + .orderBy(orderByColumn, orderByDirection); - console.log('dataQuery', dataQuery); + console.log('\n'); + console.log('\n'); + console.log('\n'); + console.log('\n'); + console.log('\n'); + console.log('finalQuery', finalQuery.compile()); + console.log('\n'); + console.log('\n'); + console.log('\n'); + console.log('\n'); + console.log('\n'); + + console.log('finalQuery', await finalQuery.execute()); const [data, count] = await Promise.all([ prisma.document.findMany({ @@ -214,6 +350,8 @@ export const findDocuments = async ({ }), ]); + console.log('prisma query', data); + const maskedData = data.map((document) => maskRecipientTokensForDocument({ document, From 409d8aa5a2458cbfedec5232d84d22b8279e115a Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 2 Apr 2024 16:13:03 +0300 Subject: [PATCH 13/56] chore: almost done? --- .../server-only/document/find-documents.ts | 344 +++++++++++++----- 1 file changed, 254 insertions(+), 90 deletions(-) diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index d5d0b8e7b..50761bd87 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -182,16 +182,181 @@ export const findDocuments = async ({ } if (team) { - console.log('team'); + console.log('team acc'); + + if (ExtendedDocumentStatus.ALL === status) { + dataQuery = dataQuery.where((eb) => { + const ors = [eb('Document.teamId', '=', team.id)]; + + if (team.teamEmail) { + ors.push( + eb.and([ + eb.not(eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.DRAFT)), + eb.exists( + eb + .selectFrom('Recipient') + .selectAll('Recipient') + .whereRef('Recipient.documentId', '=', 'Document.id') + .where('Recipient.email', '=', team.teamEmail.email), + ), + ]), + ); + + ors.push( + eb.exists( + eb + .selectFrom('User') + .selectAll('User') + .where('User.email', '=', team.teamEmail.email), + ), + ); + } + + return eb.or(ors); + }); + } else if (ExtendedDocumentStatus.INBOX === status) { + if (team.teamEmail) { + dataQuery = dataQuery.where((eb) => { + const ands = []; + + if (team.teamEmail) { + ands.push( + eb.and([ + eb.not( + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.DRAFT), + ), + eb.exists( + eb + .selectFrom('Recipient') + .selectAll('Recipient') + .whereRef('Recipient.documentId', '=', 'Document.id') + .where('Recipient.email', '=', team.teamEmail.email) + .where( + sql`CAST("Recipient"."signingStatus" AS TEXT)`, + '=', + SigningStatus.NOT_SIGNED, + ) + .where(sql`CAST("Recipient"."role" AS TEXT)`, '!=', RecipientRole.CC), + ), + ]), + ); + } + + return eb.and(ands); + }); + } + } else if (ExtendedDocumentStatus.DRAFT === status) { + dataQuery = dataQuery.where((eb) => { + const ors = [ + eb.and([ + eb('Document.teamId', '=', team.id), + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.DRAFT), + ]), + ]; + + if (team.teamEmail) { + ors.push( + eb.and([ + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.DRAFT), + eb.exists( + eb + .selectFrom('User') + .selectAll('User') + .whereRef('userId', '=', 'Document.id') + .where('User.email', '=', team.teamEmail.email), + ), + ]), + ); + } + + return eb.or(ors); + }); + } else if (ExtendedDocumentStatus.PENDING === status) { + dataQuery = dataQuery.where((eb) => { + const ors = [ + eb.and([ + eb('Document.teamId', '=', team.id), + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.PENDING), + ]), + ]; + + if (team.teamEmail) { + ors.push( + eb.or([ + eb.and([ + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.PENDING), + eb.and([ + eb.exists( + eb + .selectFrom('User') + .selectAll('User') + .whereRef('userId', '=', 'Document.id') + .where('User.email', '=', team.teamEmail.email), + ), + eb.exists( + eb + .selectFrom('Recipient') + .selectAll('Recipient') + .where('Recipient.email', '=', team.teamEmail.email) + .where( + sql`CAST("Recipient"."signingStatus" AS TEXT)`, + '=', + SigningStatus.SIGNED, + ) + .where(sql`CAST("Recipient"."role" AS TEXT)`, '!=', RecipientRole.CC), + ), + ]), + ]), + ]), + ); + } + + return eb.or(ors); + }); + } else if (ExtendedDocumentStatus.COMPLETED === status) { + dataQuery = dataQuery.where((eb) => { + const ors = []; + + if (team.teamEmail) { + ors.push( + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.COMPLETED), + eb.or([ + eb('Document.teamId', '=', team.id), + eb.and([ + eb.exists( + eb + .selectFrom('User') + .selectAll('User') + .whereRef('userId', '=', 'Document.id') + .where('User.email', '=', team.teamEmail.email), + ), + eb.exists( + eb + .selectFrom('Recipient') + .selectAll('Recipient') + .where('Recipient.email', '=', team.teamEmail.email) + .where( + sql`CAST("Recipient"."signingStatus" AS TEXT)`, + '=', + SigningStatus.SIGNED, + ) + .where(sql`CAST("Recipient"."role" AS TEXT)`, '!=', RecipientRole.CC), + ), + ]), + ]), + ); + } + + return eb.and(ors); + }); + } } else if (user) { - if (ExtendedDocumentStatus.ALL) { - console.log('inside EXTENDED_DOCUMENT_STATUS.ALL'); + if (ExtendedDocumentStatus.ALL === status) { dataQuery = dataQuery.where(({ eb, or, and, exists }) => { return or([ - eb('Document.userId', '=', user.id), - eb('Document.teamId', '=', null), + and([eb('Document.userId', '=', user.id), eb('Document.teamId', 'is', null)]), and([ - eb(sql`"Document"."status"::text`, '=', sql`${ExtendedDocumentStatus.COMPLETED}`), + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.COMPLETED), exists( eb .selectFrom('Recipient') @@ -201,7 +366,73 @@ export const findDocuments = async ({ ), ]), and([ - eb(sql`"Document"."status"::text`, '=', sql`${ExtendedDocumentStatus.PENDING}`), + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.PENDING), + exists( + eb + .selectFrom('Recipient') + .selectAll('Recipient') + .whereRef('Recipient.documentId', '=', 'Document.id') + .where('Recipient.email', '=', user.email), + ), + ]), + ]); + }); + } else if (ExtendedDocumentStatus.INBOX === status) { + dataQuery = dataQuery.where(({ eb, and, not, exists }) => { + return and([ + not(eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.DRAFT)), + exists( + eb + .selectFrom('Recipient') + .selectAll('Recipient') + .whereRef('Recipient.documentId', '=', 'Document.id') + .where('Recipient.email', '=', user.email) + .where(sql`CAST("Recipient"."signingStatus" AS TEXT)`, '=', SigningStatus.NOT_SIGNED) + .where(sql`CAST("Recipient"."role" AS TEXT)`, '!=', RecipientRole.CC), + ), + ]); + }); + } else if (ExtendedDocumentStatus.DRAFT === status) { + dataQuery = dataQuery.where(({ eb, and }) => { + return and([ + eb('Document.userId', '=', user.id), + eb('Document.teamId', 'is', null), + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.DRAFT), + ]); + }); + } else if (ExtendedDocumentStatus.PENDING === status) { + dataQuery = dataQuery.where(({ eb, or, and, exists }) => { + return or([ + and([ + eb('Document.userId', '=', user.id), + eb('Document.teamId', 'is', null), + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.PENDING), + ]), + and([ + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.PENDING), + exists( + eb + .selectFrom('Recipient') + .selectAll('Recipient') + .whereRef('Recipient.documentId', '=', 'Document.id') + .where('Recipient.email', '=', user.email) + .where(sql`CAST("Recipient"."signingStatus" AS TEXT)`, '=', SigningStatus.SIGNED) + .where(sql`CAST("Recipient"."role" AS TEXT)`, '!=', RecipientRole.CC), + ), + ]), + ]); + }); + } else if (ExtendedDocumentStatus.COMPLETED === status) { + console.log('completed bitches'); + dataQuery = dataQuery.where(({ eb, or, exists, and }) => { + return or([ + and([ + eb('Document.userId', '=', user.id), + eb('Document.teamId', 'is', null), + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.COMPLETED), + ]), + and([ + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.COMPLETED), exists( eb .selectFrom('Recipient') @@ -213,72 +444,6 @@ export const findDocuments = async ({ ]); }); } - // } else if (ExtendedDocumentStatus.INBOX) { - // dataQuery = dataQuery.where(({ eb, and, not, exists }) => { - // return and([ - // not(eb(sql`status::text`, '=', ExtendedDocumentStatus.DRAFT)), - // exists( - // eb - // .selectFrom('Recipient') - // .selectAll('Recipient') - // .whereRef('Recipient.documentId', '=', 'Document.id') - // .where('Recipient.email', '=', user.email) - // .where(sql`Recipient.signingStatus::text`, '=', SigningStatus.NOT_SIGNED) - // .where('Recipient.role', '<>', RecipientRole.CC), - // ), - // ]); - // }); - // } else if (ExtendedDocumentStatus.DRAFT) { - // dataQuery = dataQuery.where(({ eb, and }) => { - // return and([ - // eb('Document.userId', '=', user.id), - // eb('Document.teamId', '=', null), - // eb(sql`status::text`, '=', ExtendedDocumentStatus.DRAFT), - // ]); - // }); - // } else if (ExtendedDocumentStatus.PENDING) { - // dataQuery = dataQuery.where(({ eb, or, and, exists }) => { - // return or([ - // and([ - // eb('Document.userId', '=', user.id), - // eb('Document.teamId', '=', null), - // eb(sql`status::text`, '=', ExtendedDocumentStatus.PENDING), - // ]), - // and([ - // eb(sql`status::text`, '=', ExtendedDocumentStatus.PENDING), - // exists( - // eb - // .selectFrom('Recipient') - // .selectAll('Recipient') - // .whereRef('Recipient.documentId', '=', 'Document.id') - // .where('Recipient.email', '=', user.email) - // .where(sql`Recipient.signingStatus::text`, '=', SigningStatus.SIGNED) - // .where('Recipient.role', '<>', RecipientRole.CC), - // ), - // ]), - // ]); - // }); - // } else if (ExtendedDocumentStatus.COMPLETED) { - // dataQuery = dataQuery.where(({ eb, or, exists, and }) => { - // return or([ - // and([ - // eb('Document.userId', '=', user.id), - // eb('Document.teamId', '=', null), - // eb(sql`status::text`, '=', ExtendedDocumentStatus.COMPLETED), - // ]), - // and([ - // eb(sql`status::text`, '=', ExtendedDocumentStatus.COMPLETED), - // exists( - // eb - // .selectFrom('Recipient') - // .selectAll('Recipient') - // .whereRef('Recipient.documentId', '=', 'Document.id') - // .where('Recipient.email', '=', user.email), - // ), - // ]), - // ]); - // }); - // } } else { return { data: [], @@ -289,37 +454,36 @@ export const findDocuments = async ({ }; } - // dataQuery = dataQuery.where(({ eb, or, and }) => - // and([ - // or([ - // eb(sql`"Document"."status"::text`, '=', sql`${ExtendedDocumentStatus.COMPLETED}`), - // and([ - // eb(sql`"Document"."status"::text`, '<>', sql`${ExtendedDocumentStatus.COMPLETED}`), - // eb('Document.deletedAt', '=', null), - // ]), - // ]), - // ]), - // ); + dataQuery = dataQuery.where(({ eb, or, and, not }) => { + return and([ + or([ + eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.COMPLETED), + and([ + not(eb(sql`CAST("Document"."status" AS TEXT)`, '=', ExtendedDocumentStatus.COMPLETED)), + eb('Document.deletedAt', 'is', null), + ]), + ]), + ]); + }); - const finalQuery = dataQuery + const finalQuery = await dataQuery .offset(Math.max(page - 1, 0) * perPage) .limit(perPage) - .orderBy(orderByColumn, orderByDirection); + .orderBy(orderByColumn, orderByDirection) + .execute(); console.log('\n'); console.log('\n'); console.log('\n'); console.log('\n'); console.log('\n'); - console.log('finalQuery', finalQuery.compile()); + console.log('finalQuery', finalQuery); console.log('\n'); console.log('\n'); console.log('\n'); console.log('\n'); console.log('\n'); - console.log('finalQuery', await finalQuery.execute()); - const [data, count] = await Promise.all([ prisma.document.findMany({ where: whereClause, @@ -350,7 +514,7 @@ export const findDocuments = async ({ }), ]); - console.log('prisma query', data); + console.log('prisma query data', data); const maskedData = data.map((document) => maskRecipientTokensForDocument({ From 82792864de781fa58d38fe021fc5568db87a8a22 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 2 Apr 2024 16:18:07 +0300 Subject: [PATCH 14/56] chore: remove unintended console logs --- packages/lib/server-only/document/find-documents.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 50761bd87..300b9cb1b 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -182,8 +182,6 @@ export const findDocuments = async ({ } if (team) { - console.log('team acc'); - if (ExtendedDocumentStatus.ALL === status) { dataQuery = dataQuery.where((eb) => { const ors = [eb('Document.teamId', '=', team.id)]; @@ -423,7 +421,6 @@ export const findDocuments = async ({ ]); }); } else if (ExtendedDocumentStatus.COMPLETED === status) { - console.log('completed bitches'); dataQuery = dataQuery.where(({ eb, or, exists, and }) => { return or([ and([ From 7f7e7da3afa054a412938bd437899107ae20e0e5 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 3 Apr 2024 11:23:19 +0300 Subject: [PATCH 15/56] chore: format the final query data and return it --- .../lib/server-only/document/find-documents.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 300b9cb1b..c9c39046b 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -513,7 +513,23 @@ export const findDocuments = async ({ console.log('prisma query data', data); - const maskedData = data.map((document) => + const formattedFinalQuery = data.map((item) => ({ + id: item.id, + userId: item.userId, + title: item.title, + status: item.status, + documentDataId: item.documentDataId, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + completedAt: item.completedAt, + deletedAt: item.deletedAt, + teamId: item.teamId, + team: item.team, + User: item.User, + Recipient: item.Recipient, + })); + + const maskedData = formattedFinalQuery.map((document) => maskRecipientTokensForDocument({ document, user, From 60c26a9f75999763ba7c997ff6274838d940ddda Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 3 Apr 2024 14:53:40 +0300 Subject: [PATCH 16/56] chore: finished converting to kysely --- .../server-only/document/find-documents.ts | 73 ++++++++++++------- 1 file changed, 45 insertions(+), 28 deletions(-) diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index c9c39046b..547506907 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -469,18 +469,6 @@ export const findDocuments = async ({ .orderBy(orderByColumn, orderByDirection) .execute(); - console.log('\n'); - console.log('\n'); - console.log('\n'); - console.log('\n'); - console.log('\n'); - console.log('finalQuery', finalQuery); - console.log('\n'); - console.log('\n'); - console.log('\n'); - console.log('\n'); - console.log('\n'); - const [data, count] = await Promise.all([ prisma.document.findMany({ where: whereClause, @@ -511,23 +499,52 @@ export const findDocuments = async ({ }), ]); - console.log('prisma query data', data); + const formattedFinalQuery = finalQuery.map((item) => { + return { + id: item.id, + userId: item.userId, + title: item.title, + templateId: item.templateId, + status: item.status, + documentDataId: item.documentDataId, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + completedAt: item.completedAt, + deletedAt: item.deletedAt, + teamId: item.teamId, + team: item.team + ? { + id: item.team.id, + url: item.team.url, + } + : null, + User: { + id: item.User?.id, + name: item.User?.name, // Ensure this can be string or null as per the expected type + email: item.User?.email, + }, + Recipient: Array.isArray(item.Recipient) + ? item.Recipient.map((recipient) => ({ + id: recipient?.id, + documentId: recipient?.documentId, + templateId: recipient?.templateId, + email: recipient?.email, + name: recipient?.name, + role: recipient?.role, + signingStatus: recipient?.signingStatus, + token: recipient?.token, + expired: recipient?.expired, + readStatus: recipient?.readStatus, + sendStatus: recipient?.sendStatus, + signedAt: recipient?.signedAt, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + })) + : [], + }; + }); - const formattedFinalQuery = data.map((item) => ({ - id: item.id, - userId: item.userId, - title: item.title, - status: item.status, - documentDataId: item.documentDataId, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - completedAt: item.completedAt, - deletedAt: item.deletedAt, - teamId: item.teamId, - team: item.team, - User: item.User, - Recipient: item.Recipient, - })); + console.log('formattedFinalQuery', formattedFinalQuery); const maskedData = formattedFinalQuery.map((document) => maskRecipientTokensForDocument({ From 02921e53de90bf5a69a9df7561235f4ae0412850 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Thu, 4 Apr 2024 10:51:41 +0300 Subject: [PATCH 17/56] chore: trying to fix the issues --- .../server-only/document/find-documents.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 547506907..246e4521b 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -512,16 +512,20 @@ export const findDocuments = async ({ completedAt: item.completedAt, deletedAt: item.deletedAt, teamId: item.teamId, - team: item.team - ? { - id: item.team.id, - url: item.team.url, - } - : null, + team: + item.team && 'id' in item.team && 'url' in item.team + ? { + id: item.team.id, + url: item.team.url, + } + : null, User: { - id: item.User?.id, - name: item.User?.name, // Ensure this can be string or null as per the expected type - email: item.User?.email, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + id: (item.User as { id: number }).id, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + name: (item.User as { name: string | null }).name, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + email: (item.User as { email: string }).email, }, Recipient: Array.isArray(item.Recipient) ? item.Recipient.map((recipient) => ({ @@ -544,8 +548,6 @@ export const findDocuments = async ({ }; }); - console.log('formattedFinalQuery', formattedFinalQuery); - const maskedData = formattedFinalQuery.map((document) => maskRecipientTokensForDocument({ document, From e0440fd8a2133b9e4591fd3ac40b73b98fb3a9c2 Mon Sep 17 00:00:00 2001 From: Matt Kilgore Date: Sat, 13 Apr 2024 20:46:08 -0400 Subject: [PATCH 18/56] feat: add oidc support --- apps/web/process-env.d.ts | 4 ++ .../src/app/(unauthenticated)/signin/page.tsx | 9 ++-- .../src/app/(unauthenticated)/signup/page.tsx | 3 +- apps/web/src/components/forms/signin.tsx | 38 +++++++++++++++- apps/web/src/components/forms/signup.tsx | 43 ++++++++++++++++++- apps/web/src/components/forms/v2/signup.tsx | 40 ++++++++++++++++- packages/lib/constants/auth.ts | 7 +++ packages/lib/next-auth/auth-options.ts | 19 ++++++++ .../migration.sql | 1 + packages/prisma/schema.prisma | 1 + packages/tsconfig/process-env.d.ts | 4 ++ turbo.json | 3 ++ 12 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 packages/prisma/migrations/20240413202001_add_oidc_auth/migration.sql diff --git a/apps/web/process-env.d.ts b/apps/web/process-env.d.ts index 0c00cb4c1..63a341060 100644 --- a/apps/web/process-env.d.ts +++ b/apps/web/process-env.d.ts @@ -12,5 +12,9 @@ declare namespace NodeJS { NEXT_PRIVATE_GOOGLE_CLIENT_ID: string; NEXT_PRIVATE_GOOGLE_CLIENT_SECRET: string; + + NEXT_PRIVATE_OIDC_WELL_KNOWN: string; + NEXT_PRIVATE_OIDC_CLIENT_ID: string; + NEXT_PRIVATE_OIDC_CLIENT_SECRET: string; } } diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx index 21136f2e6..a0599ac1a 100644 --- a/apps/web/src/app/(unauthenticated)/signin/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'; import { env } from 'next-runtime-env'; -import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; +import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { SignInForm } from '~/components/forms/signin'; @@ -37,10 +37,13 @@ export default function SignInPage({ searchParams }: SignInPageProps) {

Welcome back, we are lucky to have you.

-
- + {NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (

diff --git a/apps/web/src/app/(unauthenticated)/signup/page.tsx b/apps/web/src/app/(unauthenticated)/signup/page.tsx index c7284fac6..2373af770 100644 --- a/apps/web/src/app/(unauthenticated)/signup/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signup/page.tsx @@ -3,7 +3,7 @@ import { redirect } from 'next/navigation'; import { env } from 'next-runtime-env'; -import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; +import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { SignUpFormV2 } from '~/components/forms/v2/signup'; @@ -37,6 +37,7 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) { className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16" initialEmail={email || undefined} isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} + isOIDCSSOEnabled={IS_OIDC_SSO_ENABLED} /> ); } diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 8d4dd7cd0..6b1e25539 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -10,6 +10,7 @@ import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/br import { KeyRoundIcon } from 'lucide-react'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; +import { FaIdCardClip } from 'react-icons/fa6'; import { FcGoogle } from 'react-icons/fc'; import { match } from 'ts-pattern'; import { z } from 'zod'; @@ -68,9 +69,15 @@ export type SignInFormProps = { className?: string; initialEmail?: string; isGoogleSSOEnabled?: boolean; + isOIDCSSOEnabled?: boolean; }; -export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => { +export const SignInForm = ({ + className, + initialEmail, + isGoogleSSOEnabled, + isOIDCSSOEnabled, +}: SignInFormProps) => { const { toast } = useToast(); const { getFlag } = useFeatureFlags(); @@ -256,6 +263,19 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign } }; + const onSignInWithOIDCClick = async () => { + try { + await signIn('oidc', { callbackUrl: LOGIN_REDIRECT_PATH }); + } catch (err) { + toast({ + title: 'An unknown error occurred', + description: + 'We encountered an unknown error while attempting to sign you In. Please try again later.', + variant: 'destructive', + }); + } + }; + return (

- {(isGoogleSSOEnabled || isPasskeyEnabled) && ( + {(isGoogleSSOEnabled || isPasskeyEnabled || isOIDCSSOEnabled) && (
Or continue with @@ -338,6 +358,20 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign )} + {isOIDCSSOEnabled && ( + + )} + {isPasskeyEnabled && ( + + )} ); diff --git a/apps/web/src/components/forms/v2/signup.tsx b/apps/web/src/components/forms/v2/signup.tsx index b3b502993..4c177ddef 100644 --- a/apps/web/src/components/forms/v2/signup.tsx +++ b/apps/web/src/components/forms/v2/signup.tsx @@ -10,6 +10,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatePresence, motion } from 'framer-motion'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; +import { FaIdCardClip } from 'react-icons/fa6'; import { FcGoogle } from 'react-icons/fc'; import { z } from 'zod'; @@ -73,12 +74,14 @@ export type SignUpFormV2Props = { className?: string; initialEmail?: string; isGoogleSSOEnabled?: boolean; + isOIDCSSOEnabled?: boolean; }; export const SignUpFormV2 = ({ className, initialEmail, isGoogleSSOEnabled, + isOIDCSSOEnabled, }: SignUpFormV2Props) => { const { toast } = useToast(); const analytics = useAnalytics(); @@ -179,6 +182,19 @@ export const SignUpFormV2 = ({ } }; + const onSignUpWithOIDCClick = async () => { + try { + await signIn('oidc', { callbackUrl: SIGN_UP_REDIRECT_PATH }); + } catch (err) { + toast({ + title: 'An unknown error occurred', + description: + 'We encountered an unknown error while attempting to sign you Up. Please try again later.', + variant: 'destructive', + }); + } + }; + return (
@@ -255,7 +271,7 @@ export const SignUpFormV2 = ({
@@ -323,14 +339,18 @@ export const SignUpFormV2 = ({ )} /> - {isGoogleSSOEnabled && ( + {(isGoogleSSOEnabled || isOIDCSSOEnabled) && ( <>
Or
+ + )} + {isGoogleSSOEnabled && ( + <> + + )} +

Already have an account?{' '} diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 137ebe640..4df19b407 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -5,12 +5,19 @@ export const SALT_ROUNDS = 12; export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = { [IdentityProvider.DOCUMENSO]: 'Documenso', [IdentityProvider.GOOGLE]: 'Google', + [IdentityProvider.OIDC]: 'OIDC', }; export const IS_GOOGLE_SSO_ENABLED = Boolean( process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET, ); +export const IS_OIDC_SSO_ENABLED = Boolean( + process.env.NEXT_PRIVATE_OIDC_WELL_KNOWN && + process.env.NEXT_PRIVATE_OIDC_CLIENT_ID && + process.env.NEXT_PRIVATE_OIDC_CLIENT_SECRET, +); + export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = { [UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO', [UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated', diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 6805eedbe..e05fae573 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -136,6 +136,25 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { }; }, }), + { + id: 'oidc', + name: 'OIDC', + wellKnown: process.env.NEXT_PRIVATE_OIDC_WELL_KNOWN, + clientId: process.env.NEXT_PRIVATE_OIDC_CLIENT_ID, + clientSecret: process.env.NEXT_PRIVATE_OIDC_CLIENT_SECRET, + authorization: { params: { scope: 'openid email profile' } }, + idToken: true, + checks: ['pkce', 'state'], + type: 'oauth', + allowDangerousEmailAccountLinking: true, + profile(profile) { + return { + id: Number(profile.sub), + email: profile.email, + name: profile.name || `${profile.given_name} ${profile.family_name}`.trim(), + }; + }, + }, CredentialsProvider({ id: 'webauthn', name: 'Keypass', diff --git a/packages/prisma/migrations/20240413202001_add_oidc_auth/migration.sql b/packages/prisma/migrations/20240413202001_add_oidc_auth/migration.sql new file mode 100644 index 000000000..929ae8d97 --- /dev/null +++ b/packages/prisma/migrations/20240413202001_add_oidc_auth/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "IdentityProvider" ADD VALUE IF NOT EXISTS 'OIDC'; \ No newline at end of file diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 35d429779..c707ff08b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -11,6 +11,7 @@ datasource db { enum IdentityProvider { DOCUMENSO GOOGLE + OIDC } enum Role { diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 0e05004a4..6af9c44c2 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -6,6 +6,10 @@ declare namespace NodeJS { NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string; NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string; + NEXT_PRIVATE_OIDC_WELL_KNOWN?: string; + NEXT_PRIVATE_OIDC_CLIENT_ID?: string; + NEXT_PRIVATE_OIDC_CLIENT_SECRET?: string; + NEXT_PRIVATE_DATABASE_URL: string; NEXT_PRIVATE_ENCRYPTION_KEY: string; NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: string; diff --git a/turbo.json b/turbo.json index fa89193eb..6accaee57 100644 --- a/turbo.json +++ b/turbo.json @@ -70,6 +70,9 @@ "NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS", "NEXT_PRIVATE_GOOGLE_CLIENT_ID", "NEXT_PRIVATE_GOOGLE_CLIENT_SECRET", + "NEXT_PRIVATE_OIDC_WELL_KNOWN", + "NEXT_PRIVATE_OIDC_CLIENT_ID", + "NEXT_PRIVATE_OIDC_CLIENT_SECRET", "NEXT_PUBLIC_UPLOAD_TRANSPORT", "NEXT_PRIVATE_UPLOAD_ENDPOINT", "NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE", From bd4a1c4c098d3130c3cc0a7642083ba06bd73d05 Mon Sep 17 00:00:00 2001 From: Matt Kilgore Date: Sat, 13 Apr 2024 21:06:24 -0400 Subject: [PATCH 19/56] fix: update .env.example --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index bc052aead..30f374d67 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF" NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" +NEXT_PRIVATE_OIDC_WELL_KNOWN="" +NEXT_PRIVATE_OIDC_CLIENT_ID="" +NEXT_PRIVATE_OIDC_CLIENT_SECRET="" + # [[URLS]] NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" NEXT_PUBLIC_MARKETING_URL="http://localhost:3001" From 788c6269a28398d698a64dd82a69c05ac3db34ee Mon Sep 17 00:00:00 2001 From: Matt Kilgore Date: Sat, 13 Apr 2024 21:16:39 -0400 Subject: [PATCH 20/56] fix: signup page oidc function --- apps/web/src/components/forms/signup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index 7cb037c1e..b5a9ffa5b 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -129,7 +129,7 @@ export const SignUpForm = ({ const onSignUpWithOIDCClick = async () => { try { - await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH }); + await signIn('oidc', { callbackUrl: SIGN_UP_REDIRECT_PATH }); } catch (err) { toast({ title: 'An unknown error occurred', From c98c1b94674c2ea73fc89ba782f35566dd5b6b2f Mon Sep 17 00:00:00 2001 From: Sumit Bisht Date: Sun, 28 Apr 2024 19:35:57 +0530 Subject: [PATCH 21/56] added teams url under a team name in teams section --- .../components/(dashboard)/layout/menu-switcher.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx index bb8429adc..4b90b5c93 100644 --- a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx +++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx @@ -170,11 +170,20 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp

{teams.map((team) => ( - + + 1 && 'group-hover:hidden')}> + {formatSecondaryAvatarText(team)} + + 1 && 'group-hover:opacity-75')} + >{`/t/${team.url}`} + + } rightSideComponent={ isPathTeamUrl(team.url) && ( From 80c03fcf3f096c645ebd7b5d85191a9816e54068 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Mon, 29 Apr 2024 04:28:13 +0530 Subject: [PATCH 22/56] feat: show time in documents table --- apps/web/src/app/(dashboard)/documents/data-table.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index fa02a1ae2..d86e2940d 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -3,6 +3,7 @@ import { useTransition } from 'react'; import { Loader } from 'lucide-react'; +import { DateTime } from 'luxon'; import { useSession } from 'next-auth/react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; @@ -62,7 +63,9 @@ export const DocumentsDataTable = ({ { header: 'Created', accessorKey: 'createdAt', - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { header: 'Title', From e82e4025400660e860c9eb090b8942ad015b0777 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 29 Apr 2024 17:10:56 +0530 Subject: [PATCH 23/56] feat: remove the existing empty signer if its the only one --- .../primitives/document-flow/add-signers.tsx | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 2f9f2f234..b83e5064c 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -138,16 +138,6 @@ export const AddSignersFormPartial = ({ ); }; - const onAddSelfSigner = () => { - appendSigner({ - formId: nanoid(12), - name: user?.name ?? '', - email: user?.email ?? '', - role: RecipientRole.SIGNER, - actionAuth: undefined, - }); - }; - const onAddSigner = () => { appendSigner({ formId: nanoid(12), @@ -174,6 +164,17 @@ export const AddSignersFormPartial = ({ removeSigner(index); }; + const onAddSelfSigner = () => { + if(signers.length !== 0) onRemoveSigner(0); + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: undefined, + }); + }; + const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { onAddSigner(); @@ -302,10 +303,10 @@ export const AddSignersFormPartial = ({ global action signing authentication method configured in the "General Settings" step - {/*
  • +
  • Require account - The recipient must be signed in -
  • */} +
  • Require passkey - The recipient must have an account and passkey configured via their settings @@ -326,13 +327,11 @@ export const AddSignersFormPartial = ({ {/* Note: -1 is remapped in the Zod schema to the required value. */} Inherit authentication method - {Object.values(RecipientActionAuth) - .filter((auth) => auth !== RecipientActionAuth.ACCOUNT) - .map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} + {Object.values(RecipientActionAuth).map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} From 917c83fc5f68a8c861a6bf3ee2449e3cd15a7593 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 29 Apr 2024 17:30:01 +0530 Subject: [PATCH 24/56] chore: refactor removal logic --- packages/ui/primitives/document-flow/add-signers.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index b83e5064c..b62d4c433 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -116,6 +116,7 @@ export const AddSignersFormPartial = ({ const onFormSubmit = form.handleSubmit(onSubmit); + const { append: appendSigner, fields: signers, @@ -124,7 +125,7 @@ export const AddSignersFormPartial = ({ control, name: 'signers', }); - + const hasBeenSentToRecipientId = (id?: number) => { if (!id) { return false; @@ -165,7 +166,10 @@ export const AddSignersFormPartial = ({ }; const onAddSelfSigner = () => { - if(signers.length !== 0) onRemoveSigner(0); + const lastSignerIndex = signers.length - 1; + if(!signers[lastSignerIndex].name || !signers[lastSignerIndex].email){ + onRemoveSigner(lastSignerIndex) + } appendSigner({ formId: nanoid(12), name: user?.name ?? '', From 6b5750c7bf8e8af051872bd49068381b11b8b3de Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 29 Apr 2024 17:48:00 +0530 Subject: [PATCH 25/56] chore: revert previous changes --- .../primitives/document-flow/add-signers.tsx | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index b62d4c433..2f9f2f234 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -116,7 +116,6 @@ export const AddSignersFormPartial = ({ const onFormSubmit = form.handleSubmit(onSubmit); - const { append: appendSigner, fields: signers, @@ -125,7 +124,7 @@ export const AddSignersFormPartial = ({ control, name: 'signers', }); - + const hasBeenSentToRecipientId = (id?: number) => { if (!id) { return false; @@ -139,6 +138,16 @@ export const AddSignersFormPartial = ({ ); }; + const onAddSelfSigner = () => { + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: undefined, + }); + }; + const onAddSigner = () => { appendSigner({ formId: nanoid(12), @@ -165,20 +174,6 @@ export const AddSignersFormPartial = ({ removeSigner(index); }; - const onAddSelfSigner = () => { - const lastSignerIndex = signers.length - 1; - if(!signers[lastSignerIndex].name || !signers[lastSignerIndex].email){ - onRemoveSigner(lastSignerIndex) - } - appendSigner({ - formId: nanoid(12), - name: user?.name ?? '', - email: user?.email ?? '', - role: RecipientRole.SIGNER, - actionAuth: undefined, - }); - }; - const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { onAddSigner(); @@ -307,10 +302,10 @@ export const AddSignersFormPartial = ({ global action signing authentication method configured in the "General Settings" step
  • -
  • + {/*
  • Require account - The recipient must be signed in -
  • + */}
  • Require passkey - The recipient must have an account and passkey configured via their settings @@ -331,11 +326,13 @@ export const AddSignersFormPartial = ({ {/* Note: -1 is remapped in the Zod schema to the required value. */} Inherit authentication method - {Object.values(RecipientActionAuth).map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} + {Object.values(RecipientActionAuth) + .filter((auth) => auth !== RecipientActionAuth.ACCOUNT) + .map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} From bde0f5893fa0f5a1b0f338cb978cbddb85c6b4eb Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 29 Apr 2024 17:49:50 +0530 Subject: [PATCH 26/56] feat: update add self signer logic --- .../primitives/document-flow/add-signers.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 2f9f2f234..3227cdeb6 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -138,16 +138,6 @@ export const AddSignersFormPartial = ({ ); }; - const onAddSelfSigner = () => { - appendSigner({ - formId: nanoid(12), - name: user?.name ?? '', - email: user?.email ?? '', - role: RecipientRole.SIGNER, - actionAuth: undefined, - }); - }; - const onAddSigner = () => { appendSigner({ formId: nanoid(12), @@ -174,6 +164,20 @@ export const AddSignersFormPartial = ({ removeSigner(index); }; + const onAddSelfSigner = () => { + const lastSignerIndex = signers.length - 1; + if(!signers[lastSignerIndex].name || !signers[lastSignerIndex].email){ + onRemoveSigner(lastSignerIndex) + } + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: undefined, + }); + }; + const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' && event.target instanceof HTMLInputElement) { onAddSigner(); From db9e605031ed5a57e5c86f28d5845860fbf15707 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 30 Apr 2024 01:32:58 +0530 Subject: [PATCH 27/56] chore: fix lint issues Signed-off-by: Adithya Krishna --- packages/ui/primitives/document-flow/add-signers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 3227cdeb6..2b3a9592d 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -166,8 +166,8 @@ export const AddSignersFormPartial = ({ const onAddSelfSigner = () => { const lastSignerIndex = signers.length - 1; - if(!signers[lastSignerIndex].name || !signers[lastSignerIndex].email){ - onRemoveSigner(lastSignerIndex) + if (!signers[lastSignerIndex].name || !signers[lastSignerIndex].email) { + onRemoveSigner(lastSignerIndex); } appendSigner({ formId: nanoid(12), From 6df525b670f0b6e571b22ab83fa2cc1110890da1 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Tue, 30 Apr 2024 12:05:42 +0530 Subject: [PATCH 28/56] feat: updated signer logic Signed-off-by: Adithya Krishna --- .../primitives/document-flow/add-signers.tsx | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 2b3a9592d..2203b7e6d 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -110,6 +110,8 @@ export const AddSignersFormPartial = ({ const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings); const { + setValue, + getValues, formState: { errors, isSubmitting }, control, } = form; @@ -164,18 +166,29 @@ export const AddSignersFormPartial = ({ removeSigner(index); }; + const emptySignerIndex = signers.findIndex( + (signer) => + !getValues(`signers.${signers.indexOf(signer)}.name`) || + !getValues(`signers.${signers.indexOf(signer)}.email`), + ); + const onAddSelfSigner = () => { const lastSignerIndex = signers.length - 1; - if (!signers[lastSignerIndex].name || !signers[lastSignerIndex].email) { + if (!signers[lastSignerIndex].name && !signers[lastSignerIndex].email) { onRemoveSigner(lastSignerIndex); } - appendSigner({ - formId: nanoid(12), - name: user?.name ?? '', - email: user?.email ?? '', - role: RecipientRole.SIGNER, - actionAuth: undefined, - }); + if (emptySignerIndex !== -1) { + setValue(`signers.${emptySignerIndex}.name`, user?.name ?? ''); + setValue(`signers.${emptySignerIndex}.email`, user?.email ?? ''); + } else { + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: undefined, + }); + } }; const onKeyDown = (event: React.KeyboardEvent) => { From e4620efa4ad15b02aadd279632086683afd5be28 Mon Sep 17 00:00:00 2001 From: david-loe <56305409+david-loe@users.noreply.github.com> Date: Fri, 3 May 2024 14:48:39 +0200 Subject: [PATCH 29/56] fix syntax in production compose.yml --- docker/production/compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/production/compose.yml b/docker/production/compose.yml index bcbd9b857..36682ee42 100644 --- a/docker/production/compose.yml +++ b/docker/production/compose.yml @@ -58,7 +58,7 @@ services: - NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP} - - NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?:-/opt/documenso/cert.p12} + - NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12} ports: - ${PORT:-3000}:${PORT:-3000} volumes: From f363dee761087de4b80775daed70ec224b88c29f Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Tue, 7 May 2024 10:19:09 +0000 Subject: [PATCH 30/56] fix: downloaded files should have _signed --- packages/lib/client-only/download-pdf.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts index 2e450de0c..830e3428a 100644 --- a/packages/lib/client-only/download-pdf.ts +++ b/packages/lib/client-only/download-pdf.ts @@ -18,7 +18,7 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, ''); downloadFile({ - filename: `${baseTitle}.pdf`, + filename: `${baseTitle}_signed.pdf`, data: blob, }); }; From 98672560ca0db215e3816bc383061edc287e5feb Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Wed, 8 May 2024 17:02:01 +0530 Subject: [PATCH 31/56] chore: update self signer logic Signed-off-by: Adithya Krishna --- packages/ui/primitives/document-flow/add-signers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 682a3fe7a..cfee325e1 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -169,7 +169,7 @@ export const AddSignersFormPartial = ({ const onAddSelfSigner = () => { const lastSignerIndex = signers.length - 1; - if (!signers[lastSignerIndex].name && !signers[lastSignerIndex].email) { + if (signers[lastSignerIndex].email || signers[lastSignerIndex].name) { onRemoveSigner(lastSignerIndex); } if (emptySignerIndex !== -1) { @@ -268,7 +268,7 @@ export const AddSignersFormPartial = ({ disabled={ isSubmitting || hasBeenSentToRecipientId(signer.nativeId) || - signers[index].email === user?.email + signers[index].name === user?.name } onKeyDown={onKeyDown} /> From bbcbc56e70f4683ffb1f784fa683eee780c428a9 Mon Sep 17 00:00:00 2001 From: Rushat Gabhane Date: Wed, 8 May 2024 19:17:47 +0530 Subject: [PATCH 32/56] feat: 12h format --- apps/web/src/app/(dashboard)/documents/data-table.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index d86e2940d..c079e0165 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -64,7 +64,10 @@ export const DocumentsDataTable = ({ header: 'Created', accessorKey: 'createdAt', cell: ({ row }) => ( - + ), }, { From 4c0b772fc9698b3099ba4e16d7e594c39f2d4fb6 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 22 May 2024 21:58:30 +1000 Subject: [PATCH 33/56] fix: rewrite form flattening handler Previously we used the form flattening method from PDF-Lib but unfortunately when it encountered orphaned form items or other PDF oddities it would throw an error. Because of this certain documents would fail to seal and be stuck in a pending state with no recourse available. This change rewrites the form flattening handler to be more lenient when coming across the unknown opting to skip items it can't handle rather than abort. --- .../admin/documents/[id]/admin-actions.tsx | 10 +- .../(dashboard)/admin/documents/[id]/page.tsx | 2 +- .../lib/server-only/document/seal-document.ts | 6 +- packages/lib/server-only/pdf/flatten-form.ts | 112 ++++++++++++++++++ packages/trpc/server/admin-router/router.ts | 8 +- 5 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 packages/lib/server-only/pdf/flatten-form.ts diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/admin-actions.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/admin-actions.tsx index 5d6cae4af..f084b5db5 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/[id]/admin-actions.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/admin-actions.tsx @@ -2,7 +2,8 @@ import Link from 'next/link'; -import { type Document, DocumentStatus } from '@documenso/prisma/client'; +import type { Recipient } from '@documenso/prisma/client'; +import { type Document, SigningStatus } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -17,9 +18,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminActionsProps = { className?: string; document: Document; + recipients: Recipient[]; }; -export const AdminActions = ({ className, document }: AdminActionsProps) => { +export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => { const { toast } = useToast(); const { mutate: resealDocument, isLoading: isResealDocumentLoading } = @@ -47,7 +49,9 @@ export const AdminActions = ({ className, document }: AdminActionsProps) => {
  • diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index f3e4c3544..19cfb0429 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -53,7 +53,7 @@ export type AddFieldsFormProps = { recipients: Recipient[]; fields: Field[]; onSubmit: (_data: TAddFieldsFormSchema) => void; - isSinglePlayerMode?: boolean; + canGoBack?: boolean; }; export const AddFieldsFormPartial = ({ @@ -62,12 +62,13 @@ export const AddFieldsFormPartial = ({ recipients, fields, onSubmit, - isSinglePlayerMode = false, + canGoBack = false, }: AddFieldsFormProps) => { const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); const { currentStep, totalSteps, previousStep } = useStep(); const canRenderBackButtonAsRemove = - currentStep === 1 && typeof documentFlow.onBackStep === 'function' && isSinglePlayerMode; + currentStep === 1 && typeof documentFlow.onBackStep === 'function' && canGoBack; + const { control, handleSubmit, From 82848e3d2e57f0f54c546d397ffca0874a181f39 Mon Sep 17 00:00:00 2001 From: Mythie Date: Fri, 24 May 2024 18:47:03 +1000 Subject: [PATCH 44/56] fix: animate transition --- .../(dashboard)/layout/menu-switcher.tsx | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx index 4b90b5c93..4895a61b3 100644 --- a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx +++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { motion } from 'framer-motion'; import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react'; import { signOut } from 'next-auth/react'; @@ -25,6 +26,8 @@ import { DropdownMenuTrigger, } from '@documenso/ui/primitives/dropdown-menu'; +const MotionLink = motion(Link); + export type MenuSwitcherProps = { user: User; teams: GetTeamsResponse; @@ -170,19 +173,35 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
    {teams.map((team) => ( - + - 1 && 'group-hover:hidden')}> +
    + {formatSecondaryAvatarText(team)} - - 1 && 'group-hover:opacity-75')} - >{`/t/${team.url}`} - + + + {`/t/${team.url}`} +
    } rightSideComponent={ isPathTeamUrl(team.url) && ( @@ -190,7 +209,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp ) } /> - +
    ))}
    From 6650a1d72e8830a438266859e6d3e7b18731cf85 Mon Sep 17 00:00:00 2001 From: Mythie Date: Fri, 24 May 2024 23:36:28 +1000 Subject: [PATCH 45/56] feat: optional email sending for api users Introduces the ability to not send an email when sending (publishing) a document using the API. Additionally returns the signing link for each recipient when working with recipient API endpoints and returns the document object including recipients when sending documents via API. --- packages/api/v1/implementation.ts | 24 ++- packages/api/v1/schema.ts | 24 ++- .../server-only/document/send-document.tsx | 170 +++++++++--------- 3 files changed, 130 insertions(+), 88 deletions(-) diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 7e729262e..18221edb6 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -1,6 +1,7 @@ import { createNextRoute } from '@ts-rest/next'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; +import { NEXT_PUBLIC_WEBAPP_URL } 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 { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; @@ -76,7 +77,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { status: 200, body: { ...document, - recipients, + recipients: recipients.map((recipient) => ({ + ...recipient, + signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`, + })), }, }; } catch (err) { @@ -258,6 +262,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { email: recipient.email, token: recipient.token, role: recipient.role, + + signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`, })), }, }; @@ -349,6 +355,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { email: recipient.email, token: recipient.token, role: recipient.role, + + signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`, })), }, }; @@ -428,6 +436,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { email: recipient.email, token: recipient.token, role: recipient.role, + + signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`, })), }, }; @@ -435,6 +445,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { sendDocument: authenticatedMiddleware(async (args, user, team) => { const { id } = args.params; + const { sendEmail = true } = args.body ?? {}; const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id }); @@ -490,10 +501,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { // }); // } - await sendDocument({ + const { Recipient: recipients, ...sentDocument } = await sendDocument({ documentId: Number(id), userId: user.id, teamId: team?.id, + sendEmail, requestMetadata: extractNextApiRequestMetadata(args.req), }); @@ -501,6 +513,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { status: 200, body: { message: 'Document sent for signing successfully', + ...sentDocument, + recipients: recipients.map((recipient) => ({ + ...recipient, + signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`, + })), }, }; } catch (err) { @@ -585,6 +602,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { body: { ...newRecipient, documentId: Number(documentId), + signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${newRecipient.token}`, }, }; } catch (err) { @@ -650,6 +668,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { body: { ...updatedRecipient, documentId: Number(documentId), + signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${updatedRecipient.token}`, }, }; }), @@ -703,6 +722,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { body: { ...deletedRecipient, documentId: Number(documentId), + signingUrl: '', }, }; }), diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index f109df348..7f82c611e 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -45,7 +45,11 @@ export type TSuccessfulGetDocumentResponseSchema = z.infer< export type TSuccessfulDocumentResponseSchema = z.infer; -export const ZSendDocumentForSigningMutationSchema = null; +export const ZSendDocumentForSigningMutationSchema = z + .object({ + sendEmail: z.boolean().optional().default(true), + }) + .or(z.literal('').transform(() => ({ sendEmail: true }))); export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema; @@ -89,8 +93,12 @@ export const ZCreateDocumentMutationResponseSchema = z.object({ recipients: z.array( z.object({ recipientId: z.number(), + name: z.string(), + email: z.string().email().min(1), token: z.string(), role: z.nativeEnum(RecipientRole), + + signingUrl: z.string(), }), ), }); @@ -134,6 +142,8 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({ email: z.string().email().min(1), token: z.string(), role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER), + + signingUrl: z.string(), }), ), }); @@ -187,6 +197,8 @@ export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({ email: z.string().email().min(1), token: z.string(), role: z.nativeEnum(RecipientRole), + + signingUrl: z.string(), }), ), }); @@ -229,6 +241,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({ readStatus: z.nativeEnum(ReadStatus), signingStatus: z.nativeEnum(SigningStatus), sendStatus: z.nativeEnum(SendStatus), + + signingUrl: z.string(), }); export type TSuccessfulRecipientResponseSchema = z.infer; @@ -279,9 +293,11 @@ export const ZSuccessfulResponseSchema = z.object({ export type TSuccessfulResponseSchema = z.infer; -export const ZSuccessfulSigningResponseSchema = z.object({ - message: z.string(), -}); +export const ZSuccessfulSigningResponseSchema = z + .object({ + message: z.string(), + }) + .and(ZSuccessfulGetDocumentResponseSchema); export type TSuccessfulSigningResponseSchema = z.infer; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 64ddb883d..fc65e8c6e 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -28,6 +28,7 @@ export type SendDocumentOptions = { documentId: number; userId: number; teamId?: number; + sendEmail?: boolean; requestMetadata?: RequestMetadata; }; @@ -35,6 +36,7 @@ export const sendDocument = async ({ documentId, userId, teamId, + sendEmail = true, requestMetadata, }: SendDocumentOptions) => { const user = await prisma.user.findFirstOrThrow({ @@ -120,98 +122,102 @@ export const sendDocument = async ({ Object.assign(document, result); } - await Promise.all( - document.Recipient.map(async (recipient) => { - if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) { - return; - } + if (sendEmail) { + await Promise.all( + document.Recipient.map(async (recipient) => { + if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) { + return; + } - const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; + const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; - const { email, name } = recipient; - const selfSigner = email === user.email; + const { email, name } = recipient; + const selfSigner = email === user.email; - const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[ - recipient.role - ].actionVerb.toLowerCase()} it.`; + const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[ + recipient.role + ].actionVerb.toLowerCase()} it.`; - const customEmailTemplate = { - 'signer.name': name, - 'signer.email': email, - 'document.name': document.title, - }; + const customEmailTemplate = { + 'signer.name': name, + 'signer.email': email, + 'document.name': document.title, + }; - const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; - const template = createElement(DocumentInviteEmailTemplate, { - documentName: document.title, - inviterName: user.name || undefined, - inviterEmail: user.email, - assetBaseUrl, - signDocumentLink, - customBody: renderCustomEmailTemplate( - selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '', - customEmailTemplate, - ), - role: recipient.role, - selfSigner, - }); + const template = createElement(DocumentInviteEmailTemplate, { + documentName: document.title, + inviterName: user.name || undefined, + inviterEmail: user.email, + assetBaseUrl, + signDocumentLink, + customBody: renderCustomEmailTemplate( + selfSigner && !customEmail?.message + ? selfSignerCustomEmail + : customEmail?.message || '', + customEmailTemplate, + ), + role: recipient.role, + selfSigner, + }); - const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; + const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; - const emailSubject = selfSigner - ? `Please ${actionVerb.toLowerCase()} your document` - : `Please ${actionVerb.toLowerCase()} this document`; + const emailSubject = selfSigner + ? `Please ${actionVerb.toLowerCase()} your document` + : `Please ${actionVerb.toLowerCase()} this document`; - await prisma.$transaction( - async (tx) => { - await mailer.sendMail({ - to: { - address: email, - name, - }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: customEmail?.subject - ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : emailSubject, - html: render(template), - text: render(template, { plainText: true }), - }); - - await tx.recipient.update({ - where: { - id: recipient.id, - }, - data: { - sendStatus: SendStatus.SENT, - }, - }); - - await tx.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, - documentId: document.id, - user, - requestMetadata, - data: { - emailType: recipientEmailType, - recipientEmail: recipient.email, - recipientName: recipient.name, - recipientRole: recipient.role, - recipientId: recipient.id, - isResending: false, + await prisma.$transaction( + async (tx) => { + await mailer.sendMail({ + to: { + address: email, + name, }, - }), - }); - }, - { timeout: 30_000 }, - ); - }), - ); + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: customEmail?.subject + ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) + : emailSubject, + html: render(template), + text: render(template, { plainText: true }), + }); + + await tx.recipient.update({ + where: { + id: recipient.id, + }, + data: { + sendStatus: SendStatus.SENT, + }, + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + documentId: document.id, + user, + requestMetadata, + data: { + emailType: recipientEmailType, + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientRole: recipient.role, + recipientId: recipient.id, + isResending: false, + }, + }), + }); + }, + { timeout: 30_000 }, + ); + }), + ); + } const allRecipientsHaveNoActionToTake = document.Recipient.every( (recipient) => recipient.role === RecipientRole.CC, From 9b92e38c52db30dbb488148c23bedc2ef087246f Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Mon, 27 May 2024 04:17:03 +0000 Subject: [PATCH 46/56] chore: add more tests (#1079) --- .../document-flow/stepper-component.spec.ts | 205 +++++++++++++++++- 1 file changed, 203 insertions(+), 2 deletions(-) diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index 07aee6a30..0de44370a 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -1,10 +1,14 @@ import { expect, test } from '@playwright/test'; +import { DateTime } from 'luxon'; import path from 'node:path'; import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus } from '@documenso/prisma/client'; -import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; +import { + seedBlankDocument, + seedPendingDocumentWithFullFields, +} from '@documenso/prisma/seed/documents'; import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; @@ -192,6 +196,102 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie await unseedUser(user.id); }); +test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients with different roles', async ({ + page, +}) => { + const user = await seedUser(); + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // Set title + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + await page.getByLabel('Title').fill('Test Title'); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add signers + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Add 2 signers. + await page.getByPlaceholder('Email').fill('user1@example.com'); + await page.getByPlaceholder('Name').fill('User 1'); + await page.getByRole('button', { name: 'Add Signer' }).click(); + + await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2'); + await page.locator('button[role="combobox"]').nth(1).click(); + await page.getByLabel('Receives copy').click(); + await page.getByRole('button', { name: 'Add Signer' }).click(); + + await page.getByRole('textbox', { name: 'Email', exact: true }).nth(1).fill('user3@example.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(2).fill('User 3'); + await page.locator('button[role="combobox"]').nth(2).click(); + await page.getByLabel('Needs to approve').click(); + await page.getByRole('button', { name: 'Add Signer' }).click(); + + await page.getByRole('textbox', { name: 'Email', exact: true }).nth(2).fill('user4@example.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).nth(3).fill('User 4'); + await page.locator('button[role="combobox"]').nth(3).click(); + await page.getByLabel('Needs to view').click(); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add fields + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'User 1 Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Email Email' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 200, + }, + }); + + await page.getByText('User 1 (user1@example.com)').click(); + await page.getByText('User 3 (user3@example.com)').click(); + + await page.getByRole('button', { name: 'User 3 Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 500, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Email Email' }).click(); + await page.locator('canvas').click({ + position: { + x: 500, + y: 200, + }, + }); + + await page.getByRole('button', { name: 'Continue' }).click(); + + // Add subject and send + await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible(); + await page.getByRole('button', { name: 'Send' }).click(); + + await page.waitForURL('/documents'); + + // Assert document was created + await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible(); + + await unseedUser(user.id); +}); + test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => { const user = await seedUser(); const document = await seedBlankDocument(user); @@ -234,6 +334,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn await page.getByRole('link', { name: documentTitle }).click(); await page.waitForURL(/\/documents\/\d+/); + // Start signing process const url = page.url().split('/'); const documentId = url[url.length - 1]; @@ -263,6 +364,63 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn await unseedUser(user.id); }); +test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) => { + const user = await seedUser(); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: ['user@documenso.com', 'approver@documenso.com'], + recipientsCreateOptions: [ + { + email: 'user@documenso.com', + role: RecipientRole.SIGNER, + }, + { + email: 'approver@documenso.com', + role: RecipientRole.APPROVER, + }, + ], + fields: [FieldType.SIGNATURE], + }); + + for (const recipient of recipients) { + const { token, Field, role } = recipient; + + const signUrl = `/sign/${token}`; + + await page.goto(signUrl); + await expect( + page.getByRole('heading', { + name: role === RecipientRole.SIGNER ? 'Sign Document' : 'Approve Document', + }), + ).toBeVisible(); + + // Add signature. + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + for (const field of Field) { + await page.locator(`#field-${field.id}`).getByRole('button').click(); + + await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); + } + + await page.getByRole('button', { name: 'Complete' }).click(); + await page + .getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' }) + .click(); + await page.waitForURL(`${signUrl}/complete`); + } + + await unseedUser(user.id); +}); + test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({ page, }) => { @@ -333,3 +491,46 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a await unseedUser(user.id); }); + +test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', async ({ page }) => { + const user = await seedUser(); + const customDate = DateTime.local().toFormat('yyyy-MM-dd hh:mm a'); + + const { document, recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + recipients: ['user1@example.com'], + fields: [FieldType.DATE], + }); + + const { token, Field } = recipients[0]; + const [recipientField] = Field; + + await page.goto(`/sign/${token}`); + await page.waitForURL(`/sign/${token}`); + + await page.locator(`#field-${recipientField.id}`).getByRole('button').click(); + + await page.getByRole('button', { name: 'Complete' }).click(); + await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible(); + await page.getByRole('button', { name: 'Sign' }).click(); + + await page.waitForURL(`/sign/${token}/complete`); + await expect(page.getByText('Document Signed')).toBeVisible(); + + const field = await prisma.field.findFirst({ + where: { + Recipient: { + email: 'user1@example.com', + }, + documentId: Number(document.id), + }, + }); + + expect(field?.customText).toBe(customDate); + + // Check if document has been signed + const { status: completedStatus } = await getDocumentByToken(token); + expect(completedStatus).toBe(DocumentStatus.COMPLETED); + + await unseedUser(user.id); +}); From c21e30d689ce3181c931eaa21680499478ad8f28 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 28 May 2024 02:45:57 +0000 Subject: [PATCH 47/56] chore: tidy code --- .../primitives/document-flow/add-signers.tsx | 37 ++++++------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index cfee325e1..3ec73f599 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -106,11 +106,13 @@ export const AddSignersFormPartial = ({ const { setValue, - getValues, formState: { errors, isSubmitting }, control, + watch, } = form; + const watchedSigners = watch('signers'); + const onFormSubmit = form.handleSubmit(onSubmit); const { @@ -122,6 +124,11 @@ export const AddSignersFormPartial = ({ name: 'signers', }); + const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email); + const isUserAlreadyARecipient = watchedSigners.some( + (signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(), + ); + const hasBeenSentToRecipientId = (id?: number) => { if (!id) { return false; @@ -161,17 +168,7 @@ export const AddSignersFormPartial = ({ removeSigner(index); }; - const emptySignerIndex = signers.findIndex( - (signer) => - !getValues(`signers.${signers.indexOf(signer)}.name`) || - !getValues(`signers.${signers.indexOf(signer)}.email`), - ); - const onAddSelfSigner = () => { - const lastSignerIndex = signers.length - 1; - if (signers[lastSignerIndex].email || signers[lastSignerIndex].name) { - onRemoveSigner(lastSignerIndex); - } if (emptySignerIndex !== -1) { setValue(`signers.${emptySignerIndex}.name`, user?.name ?? ''); setValue(`signers.${emptySignerIndex}.email`, user?.email ?? ''); @@ -235,11 +232,7 @@ export const AddSignersFormPartial = ({ type="email" placeholder="Email" {...field} - disabled={ - isSubmitting || - hasBeenSentToRecipientId(signer.nativeId) || - signers[index].email === user?.email - } + disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)} onKeyDown={onKeyDown} /> @@ -265,11 +258,7 @@ export const AddSignersFormPartial = ({ @@ -352,14 +341,12 @@ export const AddSignersFormPartial = ({ Add Signer + + + +
    + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx index 69a38f0a9..36071ffee 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx @@ -13,7 +13,9 @@ import type { Team } from '@documenso/prisma/client'; import { TemplateType } from '~/components/formatter/template-type'; +import { TemplateDirectLinkBadge } from '../template-direct-link-badge'; import { EditTemplateForm } from './edit-template'; +import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper'; export type TemplatePageViewProps = { params: { @@ -50,17 +52,33 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) return (
    - - - Templates - +
    +
    + + + Templates + -

    - {template.title} -

    +

    + {template.title} +

    -
    - +
    + + + {template.directLink?.token && ( + + )} +
    +
    + +
    + +
    + setTemplateDirectLinkDialogOpen(true)}> + + Direct link + + setDeleteDialogOpen(true)} @@ -82,6 +89,12 @@ export const DataTableActionDropdown = ({ onOpenChange={setDuplicateDialogOpen} /> + + ; + templates: FindTemplateRow[]; perPage: number; page: number; totalPages: number; @@ -48,6 +42,7 @@ export const TemplatesDataTable = ({ teamId, }: TemplatesDataTableProps) => { const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); const { remaining } = useLimits(); @@ -88,9 +83,70 @@ export const TemplatesDataTable = ({ cell: ({ row }) => , }, { - header: 'Type', + header: () => ( +
    + Type + + + + + + +
      +
    • +

      + + Public +

      + +

      + Public templates are connected to your public profile. Any modifications + to public templates will also appear in your public profile. +

      +
    • +
    • +
      + + direct link +
      + +

      + Direct link templates contain one dynamic recipient placeholder. Anyone + with access to this link can sign the document, and it will then appear on + your documents page. +

      +
    • +
    • +

      + + {teamId ? 'Team Only' : 'Private'} +

      + +

      + {teamId + ? 'Team only templates are not linked anywhere and are visible only to your team.' + : 'Private templates can only be modified and viewed by you.'} +

      +
    • +
    +
    +
    +
    + ), accessorKey: 'type', - cell: ({ row }) => , + cell: ({ row }) => ( +
    + + + {row.original.directLink?.token && ( + + )} +
    + ), }, { header: 'Actions', diff --git a/apps/web/src/app/(dashboard)/templates/template-direct-link-badge.tsx b/apps/web/src/app/(dashboard)/templates/template-direct-link-badge.tsx new file mode 100644 index 000000000..6c02b23c9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/template-direct-link-badge.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { Link2Icon } from 'lucide-react'; + +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; +import { cn } from '@documenso/ui/lib/utils'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type TemplateDirectLinkBadgeProps = { + token: string; + enabled: boolean; + className?: string; +}; + +export const TemplateDirectLinkBadge = ({ + token, + enabled, + className, +}: TemplateDirectLinkBadgeProps) => { + const [, copy] = useCopyToClipboard(); + const { toast } = useToast(); + + const onCopyClick = async (token: string) => + copy(formatDirectTemplatePath(token)).then(() => { + toast({ + title: 'Copied to clipboard', + description: 'The direct link has been copied to your clipboard', + }); + }); + + return ( + + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx new file mode 100644 index 000000000..6874fef90 --- /dev/null +++ b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx @@ -0,0 +1,448 @@ +import { useEffect, useMemo, useState } from 'react'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react'; +import { P, match } from 'ts-pattern'; + +import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { + DIRECT_TEMPLATE_DOCUMENTATION, + DIRECT_TEMPLATE_RECIPIENT_EMAIL, +} from '@documenso/lib/constants/template'; +import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; +import { + type Recipient, + RecipientRole, + type Template, + type TemplateDirectLink, +} from '@documenso/prisma/client'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type TemplateDirectLinkDialogProps = { + template: Template & { + directLink?: Pick | null; + Recipient: Recipient[]; + }; + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE'; + +export const TemplateDirectLinkDialog = ({ + template, + open, + onOpenChange, +}: TemplateDirectLinkDialogProps) => { + const { toast } = useToast(); + const { quota, remaining } = useLimits(); + + const [, copy] = useCopyToClipboard(); + const router = useRouter(); + + const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false); + const [token, setToken] = useState(template.directLink?.token ?? null); + const [selectedRecipientId, setSelectedRecipientId] = useState(null); + const [currentStep, setCurrentStep] = useState( + token ? 'MANAGE' : 'ONBOARD', + ); + + const validDirectTemplateRecipients = useMemo( + () => template.Recipient.filter((recipient) => recipient.role !== RecipientRole.CC), + [template.Recipient], + ); + + const { + mutateAsync: createTemplateDirectLink, + isLoading: isCreatingTemplateDirectLink, + reset: resetCreateTemplateDirectLink, + } = trpcReact.template.createTemplateDirectLink.useMutation({ + onSuccess: (data) => { + setToken(data.token); + setIsEnabled(data.enabled); + setCurrentStep('MANAGE'); + + router.refresh(); + }, + onError: () => { + setSelectedRecipientId(null); + + toast({ + title: 'Something went wrong', + description: 'Unable to create direct template access. Please try again later.', + variant: 'destructive', + }); + }, + }); + + const { mutateAsync: toggleTemplateDirectLink, isLoading: isTogglingTemplateAccess } = + trpcReact.template.toggleTemplateDirectLink.useMutation({ + onSuccess: (data) => { + toast({ + title: 'Success', + description: `Direct link signing has been ${data.enabled ? 'enabled' : 'disabled'}`, + }); + }, + onError: (_ctx, data) => { + toast({ + title: 'Something went wrong', + description: `An error occurred while ${ + data.enabled ? 'enabling' : 'disabling' + } direct link signing.`, + variant: 'destructive', + }); + }, + }); + + const { mutateAsync: deleteTemplateDirectLink, isLoading: isDeletingTemplateDirectLink } = + trpcReact.template.deleteTemplateDirectLink.useMutation({ + onSuccess: () => { + onOpenChange(false); + setToken(null); + + toast({ + title: 'Success', + description: 'Direct template link deleted', + duration: 5000, + }); + + router.refresh(); + setToken(null); + }, + onError: () => { + toast({ + title: 'Something went wrong', + description: + 'We encountered an error while removing the direct template link. Please try again later.', + variant: 'destructive', + }); + }, + }); + + const onCopyClick = async (token: string) => + copy(formatDirectTemplatePath(token)).then(() => { + toast({ + title: 'Copied to clipboard', + description: 'The direct link has been copied to your clipboard', + }); + }); + + const onRecipientTableRowClick = async (recipientId: number) => { + if (isLoading) { + return; + } + + setSelectedRecipientId(recipientId); + + await createTemplateDirectLink({ + templateId: template.id, + directRecipientId: recipientId, + }); + }; + + const isLoading = + isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink; + + useEffect(() => { + resetCreateTemplateDirectLink(); + setCurrentStep(token ? 'MANAGE' : 'ONBOARD'); + setSelectedRecipientId(null); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + return ( + !isLoading && onOpenChange(value)}> +
    + + {match({ token, currentStep }) + .with({ token: P.nullish, currentStep: 'ONBOARD' }, () => ( + + + Create Direct Signing Link + + Here's how it works: + + +
      + {DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => ( +
    • +
      +
      + {index + 1} +
      +
      + +

      {step.title}

      +

      {step.description}

      +
    • + ))} +
    + + {remaining.directTemplates === 0 && ( + + + Direct template link usage exceeded ({quota.directTemplates}/ + {quota.directTemplates}) + + + You have reached the maximum limit of {quota.directTemplates} direct + templates.{' '} + + Upgrade your account to continue! + + + + )} + + {remaining.directTemplates !== 0 && ( + + + + )} +
    + )) + .with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => ( + + {isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && ( +
    + +
    + )} + + + Choose Direct Link Recipient + + + Choose an existing recipient from below to continue + + + +
    + + + + Recipient + Role + + + + + {validDirectTemplateRecipients.length === 0 && ( + + +

    No valid recipients found

    +
    +
    + )} + + {validDirectTemplateRecipients.map((row) => ( + onRecipientTableRowClick(row.id)} + > + +
    +

    {row.name}

    +

    {row.email}

    +
    +
    + + + {RECIPIENT_ROLES_DESCRIPTION[row.role].roleName} + + + + {selectedRecipientId === row.id ? ( + + ) : ( + + )} + +
    + ))} +
    +
    +
    + + {/* Prevent creating placeholder direct template recipient if the email already exists. */} + {!template.Recipient.some( + (recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL, + ) && ( + +
    + {validDirectTemplateRecipients.length !== 0 && ( +

    Or

    + )} + + +
    +
    + )} +
    + )) + .with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => ( + + + Direct Link Signing + + + Manage the direct link signing for this template + + + +
    +
    + + + setIsEnabled(value)} + /> +
    + +
    + + +
    + + +
    + +
    +
    +
    +
    + + + + + + +
    + )) + .with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => ( + + + Are you sure? + + + Please note that proceeding will remove direct linking recipient and turn it + into a placeholder. + + + + + + + + + + )) + .otherwise(() => null)} +
    +
    +
    + ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx index d144eba3b..20cba75da 100644 --- a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx @@ -172,7 +172,7 @@ export function UseTemplateDialog({ return ( !form.formState.isSubmitting && setOpen(value)}> - diff --git a/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx new file mode 100644 index 000000000..41ce61815 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/configure-direct-template.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useSession } from 'next-auth/react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import type { Field, Recipient } from '@documenso/prisma/client'; +import type { TemplateWithDetails } from '@documenso/prisma/types/template'; +import { + DocumentFlowFormContainerActions, + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerHeader, + DocumentFlowFormContainerStep, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useStep } from '@documenso/ui/primitives/stepper'; + +import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider'; + +const ZConfigureDirectTemplateFormSchema = z.object({ + email: z.string().email('Email is invalid'), +}); + +export type TConfigureDirectTemplateFormSchema = z.infer; + +export type ConfigureDirectTemplateFormProps = { + flowStep: DocumentFlowStep; + isDocumentPdfLoaded: boolean; + template: TemplateWithDetails; + directTemplateRecipient: Recipient & { Field: Field[] }; + initialEmail?: string; + onSubmit: (_data: TConfigureDirectTemplateFormSchema) => void; +}; + +export const ConfigureDirectTemplateFormPartial = ({ + flowStep, + isDocumentPdfLoaded, + template, + directTemplateRecipient, + initialEmail, + onSubmit, +}: ConfigureDirectTemplateFormProps) => { + const { Recipient } = template; + const { derivedRecipientAccessAuth } = useRequiredDocumentAuthContext(); + const { data: session } = useSession(); + + const recipientsWithBlankDirectRecipientEmail = Recipient.map((recipient) => { + if (recipient.id === directTemplateRecipient.id) { + return { + ...recipient, + email: '', + }; + } + + return recipient; + }); + + const form = useForm({ + resolver: zodResolver( + ZConfigureDirectTemplateFormSchema.superRefine((items, ctx) => { + if (template.Recipient.map((recipient) => recipient.email).includes(items.email)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Email cannot already exist in the template', + path: ['email'], + }); + } + }), + ), + defaultValues: { + email: initialEmail || '', + }, + }); + + const { stepIndex, currentStep, totalSteps, previousStep } = useStep(); + + return ( + <> + + + + {isDocumentPdfLoaded && + directTemplateRecipient.Field.map((field, index) => ( + + ))} + +
    +
    + ( + + Email + + + + + + {!fieldState.error && ( +

    + Enter your email address to receive the completed document. +

    + )} + + +
    + )} + /> +
    +
    +
    + + + + + + + + ); +}; diff --git a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx new file mode 100644 index 000000000..8bb3756f4 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import type { Field } from '@documenso/prisma/client'; +import { type Recipient } from '@documenso/prisma/client'; +import type { TemplateWithDetails } from '@documenso/prisma/types/template'; +import { trpc } from '@documenso/trpc/react'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { Stepper } from '@documenso/ui/primitives/stepper'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider'; +import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; + +import type { TConfigureDirectTemplateFormSchema } from './configure-direct-template'; +import { ConfigureDirectTemplateFormPartial } from './configure-direct-template'; +import type { DirectTemplateLocalField } from './sign-direct-template'; +import { SignDirectTemplateForm } from './sign-direct-template'; + +export type TemplatesDirectPageViewProps = { + template: TemplateWithDetails; + directTemplateToken: string; + directTemplateRecipient: Recipient & { Field: Field[] }; +}; + +type DirectTemplateStep = 'configure' | 'sign'; +const DirectTemplateSteps: DirectTemplateStep[] = ['configure', 'sign']; + +export const DirectTemplatePageView = ({ + template, + directTemplateRecipient, + directTemplateToken, +}: TemplatesDirectPageViewProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const { email, setEmail } = useRequiredSigningContext(); + const { recipient, setRecipient } = useRequiredDocumentAuthContext(); + + const [step, setStep] = useState('configure'); + const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); + + const directTemplateFlow: Record = { + configure: { + title: 'General', + description: 'Preview and configure template.', + stepIndex: 1, + }, + sign: { + title: 'Sign document', + description: 'Sign the document to complete the process.', + stepIndex: 2, + }, + }; + + const { mutateAsync: createDocumentFromDirectTemplate } = + trpc.template.createDocumentFromDirectTemplate.useMutation(); + + /** + * Set the email into a temporary recipient so it can be used for reauth and signing email fields. + */ + const onConfigureDirectTemplateSubmit = ({ email }: TConfigureDirectTemplateFormSchema) => { + setEmail(email); + + setRecipient({ + ...recipient, + email, + }); + + setStep('sign'); + }; + + const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => { + try { + const token = await createDocumentFromDirectTemplate({ + directTemplateToken, + directRecipientEmail: recipient.email, + templateUpdatedAt: template.updatedAt, + signedFieldValues: fields.map((field) => { + if (!field.signedValue) { + throw new Error('Invalid configuration'); + } + + return field.signedValue; + }), + }); + + const redirectUrl = template.templateMeta?.redirectUrl; + + redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${token}/complete`); + } catch (err) { + toast({ + title: 'Something went wrong', + description: 'We were unable to submit this document at this time. Please try again later.', + variant: 'destructive', + }); + + throw err; + } + }; + + const currentDocumentFlow = directTemplateFlow[step]; + + return ( +
    + + + setIsDocumentPdfLoaded(true)} + /> + + + +
    + e.preventDefault()} + > + setStep(DirectTemplateSteps[step - 1])} + > + + + + + +
    +
    + ); +}; diff --git a/apps/web/src/app/(recipient)/d/[token]/not-found.tsx b/apps/web/src/app/(recipient)/d/[token]/not-found.tsx new file mode 100644 index 000000000..2fe561ba1 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/not-found.tsx @@ -0,0 +1,33 @@ +'use client'; + +import Link from 'next/link'; + +import { ChevronLeft } from 'lucide-react'; + +import { Button } from '@documenso/ui/primitives/button'; + +export default function NotFound() { + return ( +
    +
    +

    404 Template not found

    + +

    Oops! Something went wrong.

    + +

    + The template you are looking for may have been disabled, deleted or may have never + existed. +

    + +
    + +
    +
    +
    + ); +} diff --git a/apps/web/src/app/(recipient)/d/[token]/page.tsx b/apps/web/src/app/(recipient)/d/[token]/page.tsx new file mode 100644 index 000000000..d7b2eb682 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/page.tsx @@ -0,0 +1,92 @@ +import { notFound, redirect } from 'next/navigation'; + +import { UsersIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token'; +import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; +import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; + +import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider'; +import { SigningProvider } from '~/app/(signing)/sign/[token]/provider'; +import { truncateTitle } from '~/helpers/truncate-title'; + +import { DirectTemplatePageView } from './direct-template'; +import { DirectTemplateAuthPageView } from './signing-auth-page'; + +export type TemplatesDirectPageProps = { + params: { + token: string; + }; +}; + +export default async function TemplatesDirectPage({ params }: TemplatesDirectPageProps) { + const { token } = params; + + if (!token) { + redirect('/'); + } + + const { user } = await getServerComponentSession(); + + const template = await getTemplateByDirectLinkToken({ + token, + }).catch(() => null); + + if (!template || !template.directLink?.enabled) { + notFound(); + } + + const directTemplateRecipient = template.Recipient.find( + (recipient) => recipient.id === template.directLink?.directTemplateRecipientId, + ); + + if (!directTemplateRecipient) { + notFound(); + } + + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ + documentAuth: template.authOptions, + }); + + // Ensure typesafety when we add more options. + const isAccessAuthValid = match(derivedRecipientAccessAuth) + .with(DocumentAccessAuth.ACCOUNT, () => user !== null) + .with(null, () => true) + .exhaustive(); + + if (!isAccessAuthValid) { + return ; + } + + return ( + + +
    +

    + {truncateTitle(template.title)} +

    + +
    + +

    + {template.Recipient.length}{' '} + {template.Recipient.length > 1 ? 'recipients' : 'recipient'} +

    +
    + + +
    +
    +
    + ); +} diff --git a/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx new file mode 100644 index 000000000..1531b6969 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx @@ -0,0 +1,278 @@ +import { useMemo, useState } from 'react'; + +import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; + +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import type { Field, Recipient, Signature } from '@documenso/prisma/client'; +import { FieldType } from '@documenso/prisma/client'; +import type { TemplateWithDetails } from '@documenso/prisma/types/template'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; +import { + DocumentFlowFormContainerContent, + DocumentFlowFormContainerFooter, + DocumentFlowFormContainerHeader, + DocumentFlowFormContainerStep, +} from '@documenso/ui/primitives/document-flow/document-flow-root'; +import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; +import { ElementVisible } from '@documenso/ui/primitives/element-visible'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { useStep } from '@documenso/ui/primitives/stepper'; + +import { DateField } from '~/app/(signing)/sign/[token]/date-field'; +import { EmailField } from '~/app/(signing)/sign/[token]/email-field'; +import { NameField } from '~/app/(signing)/sign/[token]/name-field'; +import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; +import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog'; +import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field'; +import { TextField } from '~/app/(signing)/sign/[token]/text-field'; + +export type SignDirectTemplateFormProps = { + flowStep: DocumentFlowStep; + directRecipient: Recipient; + directRecipientFields: Field[]; + template: TemplateWithDetails; + onSubmit: (_data: DirectTemplateLocalField[]) => Promise; +}; + +export type DirectTemplateLocalField = Field & { + signedValue?: TSignFieldWithTokenMutationSchema; + Signature?: Signature; +}; + +export const SignDirectTemplateForm = ({ + flowStep, + directRecipient, + directRecipientFields, + template, + onSubmit, +}: SignDirectTemplateFormProps) => { + const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); + + const [localFields, setLocalFields] = useState(directRecipientFields); + const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { currentStep, totalSteps, previousStep } = useStep(); + + const onSignField = (value: TSignFieldWithTokenMutationSchema) => { + setLocalFields( + localFields.map((field) => { + if (field.id !== value.fieldId) { + return field; + } + + const tempField: DirectTemplateLocalField = { + ...field, + customText: value.value, + inserted: true, + signedValue: value, + }; + + if (field.type === FieldType.SIGNATURE) { + tempField.Signature = { + id: 1, + created: new Date(), + recipientId: 1, + fieldId: 1, + signatureImageAsBase64: value.value, + typedSignature: null, + }; + } + + if (field.type === FieldType.DATE) { + tempField.customText = DateTime.now() + .setZone(template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE) + .toFormat(template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT); + } + return tempField; + }), + ); + }; + + const onUnsignField = (value: TRemovedSignedFieldWithTokenMutationSchema) => { + setLocalFields( + localFields.map((field) => { + if (field.id !== value.fieldId) { + return field; + } + + return { + ...field, + customText: '', + inserted: false, + signedValue: undefined, + Signature: undefined, + }; + }), + ); + }; + + const uninsertedFields = useMemo(() => { + return sortFieldsByPosition(localFields.filter((field) => !field.inserted)); + }, [localFields]); + + const fieldsValidated = () => { + setValidateUninsertedFields(true); + validateFieldsInserted(localFields); + }; + + const handleSubmit = async () => { + setValidateUninsertedFields(true); + + const isFieldsValid = validateFieldsInserted(localFields); + + if (!isFieldsValid) { + return; + } + + setIsSubmitting(true); + + try { + await onSubmit(localFields); + } catch { + setIsSubmitting(false); + } + + // Do not reset to false since we do a redirect. + }; + + return ( + <> + + + + + {validateUninsertedFields && uninsertedFields[0] && ( + + Click to insert field + + )} + + {localFields.map((field) => + match(field.type) + .with(FieldType.SIGNATURE, () => ( + + )) + .with(FieldType.NAME, () => ( + + )) + .with(FieldType.DATE, () => ( + + )) + .with(FieldType.EMAIL, () => ( + + )) + .with(FieldType.TEXT, () => ( + + )) + .otherwise(() => null), + )} + + +
    +
    +
    + + + setFullName(e.target.value.trimStart())} + /> +
    + +
    + + + + + { + setSignature(value); + }} + /> + + +
    +
    +
    +
    + + + + +
    + + + +
    +
    + + ); +}; diff --git a/apps/web/src/app/(recipient)/d/[token]/signing-auth-page.tsx b/apps/web/src/app/(recipient)/d/[token]/signing-auth-page.tsx new file mode 100644 index 000000000..a3f077ad9 --- /dev/null +++ b/apps/web/src/app/(recipient)/d/[token]/signing-auth-page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useState } from 'react'; + +import { signOut } from 'next-auth/react'; + +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export const DirectTemplateAuthPageView = () => { + const { toast } = useToast(); + + const [isSigningOut, setIsSigningOut] = useState(false); + + const handleChangeAccount = async () => { + try { + setIsSigningOut(true); + + await signOut({ + callbackUrl: '/signin', + }); + } catch { + toast({ + title: 'Something went wrong', + description: 'We were unable to log you out at this time.', + duration: 10000, + variant: 'destructive', + }); + } + + setIsSigningOut(false); + }; + + return ( +
    +
    +

    Authentication required

    + +

    + You need to be logged in to view this page. +

    + + +
    +
    + ); +}; diff --git a/apps/web/src/app/(recipient)/layout.tsx b/apps/web/src/app/(recipient)/layout.tsx new file mode 100644 index 000000000..fb4bd0622 --- /dev/null +++ b/apps/web/src/app/(recipient)/layout.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +import { getTeams } from '@documenso/lib/server-only/team/get-teams'; + +import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header'; +import { NextAuthProvider } from '~/providers/next-auth'; + +type RecipientLayoutProps = { + children: React.ReactNode; +}; + +/** + * A layout to handle scenarios where the user is a recipient of a given resource + * where we do not care whether they are authenticated or not. + * + * Such as direct template access, or signing. + */ +export default async function RecipientLayout({ children }: RecipientLayoutProps) { + const { user, session } = await getServerComponentSession(); + + let teams: GetTeamsResponse = []; + + if (user && session) { + teams = await getTeams({ userId: user.id }); + } + + return ( + +
    + {user && } + +
    {children}
    +
    +
    + ); +} diff --git a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx index 1b7adfe70..f505b0692 100644 --- a/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/complete/page.tsx @@ -67,7 +67,7 @@ export default async function CompletedSigningPage({ const isDocumentAccessValid = await isRecipientAuthorized({ type: 'ACCESS', - document, + documentAuthOptions: document.authOptions, recipient, userId: user?.id, }); @@ -131,7 +131,7 @@ export default async function CompletedSigningPage({
    )) .with({ deletedAt: null }, () => ( -
    +
    Waiting for others to sign
    diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index dc1799bc1..5bee91a9b 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -17,6 +17,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { SigningFieldContainer } from './signing-field-container'; @@ -26,6 +30,8 @@ export type DateFieldProps = { recipient: Recipient; dateFormat?: string | null; timezone?: string | null; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; export const DateField = ({ @@ -33,6 +39,8 @@ export const DateField = ({ recipient, dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT, timezone = DEFAULT_DOCUMENT_TIME_ZONE, + onSignField, + onUnsignField, }: DateFieldProps) => { const router = useRouter(); @@ -58,12 +66,19 @@ export const DateField = ({ const onSign = async (authOptions?: TRecipientActionAuth) => { try { - await signFieldWithToken({ + const payload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT, authOptions, - }); + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { @@ -85,10 +100,17 @@ export const DateField = ({ const onRemove = async () => { try { - await removeSignedFieldWithToken({ + const payload: TRemovedSignedFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - }); + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { diff --git a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx index 86f673db0..c5c32f414 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx @@ -34,9 +34,9 @@ type PasskeyData = { export type DocumentAuthContextValue = { executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise; - document: Document; + documentAuthOptions: Document['authOptions']; documentAuthOption: TDocumentAuthOptions; - setDocument: (_value: Document) => void; + setDocumentAuthOptions: (_value: Document['authOptions']) => void; recipient: Recipient; recipientAuthOption: TRecipientAuthOptions; setRecipient: (_value: Recipient) => void; @@ -69,19 +69,19 @@ export const useRequiredDocumentAuthContext = () => { }; export interface DocumentAuthProviderProps { - document: Document; + documentAuthOptions: Document['authOptions']; recipient: Recipient; user?: User | null; children: React.ReactNode; } export const DocumentAuthProvider = ({ - document: initialDocument, + documentAuthOptions: initialDocumentAuthOptions, recipient: initialRecipient, user, children, }: DocumentAuthProviderProps) => { - const [document, setDocument] = useState(initialDocument); + const [documentAuthOptions, setDocumentAuthOptions] = useState(initialDocumentAuthOptions); const [recipient, setRecipient] = useState(initialRecipient); const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false); @@ -95,10 +95,10 @@ export const DocumentAuthProvider = ({ } = useMemo( () => extractDocumentAuthMethods({ - documentAuth: document.authOptions, + documentAuth: documentAuthOptions, recipientAuth: recipient.authOptions, }), - [document, recipient], + [documentAuthOptions, recipient], ); const passkeyQuery = trpc.auth.findPasskeys.useQuery( @@ -189,8 +189,8 @@ export const DocumentAuthProvider = ({ Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const EmailField = ({ field, recipient }: EmailFieldProps) => { +export const EmailField = ({ field, recipient, onSignField, onUnsignField }: EmailFieldProps) => { const router = useRouter(); const { toast } = useToast(); @@ -43,13 +49,22 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => { const onSign = async (authOptions?: TRecipientActionAuth) => { try { - await signFieldWithToken({ + const value = providedEmail ?? ''; + + const payload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - value: providedEmail ?? '', + value, isBase64: false, authOptions, - }); + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { @@ -71,10 +86,17 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => { const onRemove = async () => { try { - await removeSignedFieldWithToken({ + const payload: TRemovedSignedFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - }); + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 70897a716..adcbfa16c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -145,7 +145,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const NameField = ({ field, recipient }: NameFieldProps) => { +export const NameField = ({ field, recipient, onSignField, onUnsignField }: NameFieldProps) => { const router = useRouter(); const { toast } = useToast(); @@ -83,13 +89,20 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { return; } - await signFieldWithToken({ + const payload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, value, isBase64: false, authOptions, - }); + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { @@ -111,10 +124,17 @@ export const NameField = ({ field, recipient }: NameFieldProps) => { const onRemove = async () => { try { - await removeSignedFieldWithToken({ + const payload: TRemovedSignedFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - }); + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index b066193e6..3ae09f662 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -65,7 +65,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp const isDocumentAccessValid = await isRecipientAuthorized({ type: 'ACCESS', - document, + documentAuthOptions: document.authOptions, recipient, userId: user?.id, }); @@ -126,7 +126,11 @@ export default async function SigningPage({ params: { token } }: SigningPageProp fullName={user?.email === recipient.email ? user.name : recipient.name} signature={user?.email === recipient.email ? user.signature : undefined} > - + void | Promise; onSignatureComplete: () => void | Promise; @@ -25,14 +25,14 @@ export type SignDialogProps = { export const SignDialog = ({ isSubmitting, - document, + documentTitle, fields, fieldsValidated, onSignatureComplete, role, }: SignDialogProps) => { const [showDialog, setShowDialog] = useState(false); - const truncatedTitle = truncateTitle(document.title); + const truncatedTitle = truncateTitle(documentTitle); const isComplete = fields.every((field) => field.inserted); const handleOpenChange = (open: boolean) => { @@ -40,18 +40,6 @@ export const SignDialog = ({ return; } - // Reauth is currently not required for signing the document. - // if (isAuthRedirectRequired) { - // await executeActionAuthProcedure({ - // actionTarget: 'DOCUMENT', - // onReauthFormSubmit: () => { - // // Do nothing since the user should be redirected. - // }, - // }); - - // return; - // } - setShowDialog(open); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index 94051f75b..e6c39ab08 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { type Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Label } from '@documenso/ui/primitives/label'; @@ -29,9 +33,16 @@ type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; export type SignatureFieldProps = { field: FieldWithSignature; recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { +export const SignatureField = ({ + field, + recipient, + onSignField, + onUnsignField, +}: SignatureFieldProps) => { const router = useRouter(); const { toast } = useToast(); @@ -105,13 +116,20 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { return; } - await signFieldWithToken({ + const payload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, value, isBase64: true, authOptions, - }); + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { @@ -133,10 +151,17 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => { const onRemove = async () => { try { - await removeSignedFieldWithToken({ + const payload: TRemovedSignedFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - }); + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { diff --git a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx index 8b78229be..ec063315d 100644 --- a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx @@ -12,6 +12,10 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Input } from '@documenso/ui/primitives/input'; @@ -24,9 +28,11 @@ import { SigningFieldContainer } from './signing-field-container'; export type TextFieldProps = { field: FieldWithSignature; recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const TextField = ({ field, recipient }: TextFieldProps) => { +export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => { const router = useRouter(); const { toast } = useToast(); @@ -81,13 +87,20 @@ export const TextField = ({ field, recipient }: TextFieldProps) => { return; } - await signFieldWithToken({ + const payload: TSignFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, value: localText, isBase64: true, authOptions, - }); + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); setLocalCustomText(''); @@ -111,10 +124,17 @@ export const TextField = ({ field, recipient }: TextFieldProps) => { const onRemove = async () => { try { - await removeSignedFieldWithToken({ + const payload: TRemovedSignedFieldWithTokenMutationSchema = { token: recipient.token, fieldId: field.id, - }); + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); startTransition(() => router.refresh()); } catch (err) { diff --git a/apps/web/src/components/formatter/template-type.tsx b/apps/web/src/components/formatter/template-type.tsx index 3bcb3b05e..f17728391 100644 --- a/apps/web/src/components/formatter/template-type.tsx +++ b/apps/web/src/components/formatter/template-type.tsx @@ -1,6 +1,6 @@ import type { HTMLAttributes } from 'react'; -import { Globe, Lock } from 'lucide-react'; +import { Globe2, Lock } from 'lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client'; @@ -22,7 +22,7 @@ const TEMPLATE_TYPES: Record = { }, PUBLIC: { label: 'Public', - icon: Globe, + icon: Globe2, color: 'text-green-500 dark:text-green-300', }, }; diff --git a/packages/app-tests/e2e/fixtures/documents.ts b/packages/app-tests/e2e/fixtures/documents.ts index f7e0bd391..160dc1030 100644 --- a/packages/app-tests/e2e/fixtures/documents.ts +++ b/packages/app-tests/e2e/fixtures/documents.ts @@ -13,5 +13,5 @@ export const checkDocumentTabCount = async (page: Page, tabName: string, count: return; } - await expect(page.getByRole('main')).toContainText(`Showing ${count}`); + await expect(page.getByTestId('data-table-count')).toContainText(`Showing ${count}`); }; diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts new file mode 100644 index 000000000..518dc28c0 --- /dev/null +++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts @@ -0,0 +1,339 @@ +import { expect, test } from '@playwright/test'; +import { customAlphabet } from 'nanoid'; + +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { + DIRECT_TEMPLATE_RECIPIENT_EMAIL, + DIRECT_TEMPLATE_RECIPIENT_NAME, +} from '@documenso/lib/constants/template'; +import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth'; +import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; +import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; +import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; +import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates'; +import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; +import { checkDocumentTabCount } from '../fixtures/documents'; + +const nanoid = customAlphabet('1234567890abcdef', 10); + +test.describe.configure({ mode: 'parallel' }); + +test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + + // Should only be visible to the owner in personal templates. + const personalTemplate = await seedTemplate({ + title: 'Personal template', + userId: owner.id, + }); + + // Should be visible to team members. + const teamTemplate = await seedTemplate({ + title: 'Team template 1', + userId: owner.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: '/templates', + }); + + const urls = [ + `${WEBAPP_BASE_URL}/t/${team.url}/templates/${teamTemplate.id}`, + `${WEBAPP_BASE_URL}/templates/${personalTemplate.id}`, + ]; + + // Run test for personal and team templates. + for (const url of urls) { + // Owner should see list of templates with no direct link badge. + await page.goto(url); + await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(1); + + // Create direct link. + await page.getByRole('button', { name: 'Create Direct Link' }).click(); + await page.getByRole('button', { name: 'Enable direct link signing' }).click(); + await page.getByRole('button', { name: 'Create one automatically' }).click(); + await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible(); + await page.getByTestId('btn-dialog-close').click(); + + // Expect badge to appear. + await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(2); + } + + await unseedTeam(team.url); +}); + +test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + + // Should only be visible to the owner in personal templates. + const personalDirectTemplate = await seedDirectTemplate({ + title: 'Personal direct template link', + userId: owner.id, + }); + + // Should be visible to team members. + const teamDirectTemplate = await seedDirectTemplate({ + title: 'Team direct template link 1', + userId: owner.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: owner.email, + }); + + // Run test for personal and team templates. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + // Check that the direct template link is accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + // Navigate to template settings and disable access. + await page.goto(`${WEBAPP_BASE_URL}${formatTemplatesPath(template.team?.url)}`); + await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click(); + await page.getByRole('menuitem', { name: 'Direct link' }).click(); + await page.getByRole('switch').click(); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Direct link signing has been').first()).toBeVisible(); + await page.getByLabel('Direct Link Signing', { exact: true }).press('Escape'); + + // Check that the direct template link is no longer accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByText('Template not found')).toBeVisible(); + } + + await unseedTeam(team.url); +}); + +test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + + // Should only be visible to the owner in personal templates. + const personalDirectTemplate = await seedDirectTemplate({ + title: 'Personal direct template link', + userId: owner.id, + }); + + // Should be visible to team members. + const teamDirectTemplate = await seedDirectTemplate({ + title: 'Team direct template link 1', + userId: owner.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: owner.email, + }); + + // Run test for personal and team templates. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + // Check that the direct template link is accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + // Navigate to template settings and delete the access. + await page.goto(`${WEBAPP_BASE_URL}${formatTemplatesPath(template.team?.url)}`); + await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click(); + await page.getByRole('menuitem', { name: 'Direct link' }).click(); + await page.getByRole('button', { name: 'Remove' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await expect(page.getByText('Direct template link deleted').first()).toBeVisible(); + + // Check that the direct template link is no longer accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByText('Template not found')).toBeVisible(); + } + + await unseedTeam(team.url); +}); + +test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => { + const user = await seedUser(); + + const directTemplateWithAuth = await seedDirectTemplate({ + title: 'Personal direct template link', + userId: user.id, + createTemplateOptions: { + authOptions: createDocumentAuthOptions({ + globalAccessAuth: 'ACCOUNT', + globalActionAuth: null, + }), + }, + }); + + const directTemplatePath = formatDirectTemplatePath( + directTemplateWithAuth.directLink?.token || '', + ); + + await page.goto(directTemplatePath); + + await expect(page.getByText('Authentication required')).toBeVisible(); + + await apiSignin({ + page, + email: user.email, + }); + + await page.goto(directTemplatePath); + + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + await expect(page.getByLabel('Email')).toBeDisabled(); + + await unseedUser(user.id); +}); + +test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + + // Should only be visible to the owner in personal templates. + const personalDirectTemplate = await seedDirectTemplate({ + title: 'Personal direct template link', + userId: owner.id, + }); + + // Should be visible to team members. + const teamDirectTemplate = await seedDirectTemplate({ + title: 'Team direct template link 1', + userId: owner.id, + teamId: team.id, + }); + + // Run test for personal and team templates. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + // Check that the direct template link is accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail()); + + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(/\/sign/); + await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible(); + } + + await apiSignin({ + page, + email: owner.email, + }); + + // Check that the owner has the documents. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + await page.goto(`${WEBAPP_BASE_URL}${formatDocumentsPath(template.team?.url)}`); + + // Check that the document is in the 'All' tab. + await checkDocumentTabCount(page, 'Completed', 1); + } + + await unseedTeam(team.url); +}); + +test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + + const secondRecipient = await seedUser(); + + const createTemplateOptions = { + Recipient: { + createMany: { + data: [ + { + email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, + name: DIRECT_TEMPLATE_RECIPIENT_NAME, + token: nanoid(), + }, + { + email: secondRecipient.email, + token: nanoid(), + }, + ], + }, + }, + }; + + // Should only be visible to the owner in personal templates. + const personalDirectTemplate = await seedDirectTemplate({ + title: 'Personal direct template link', + userId: owner.id, + createTemplateOptions, + }); + + // Should be visible to team members. + const teamDirectTemplate = await seedDirectTemplate({ + title: 'Team direct template link 1', + userId: owner.id, + teamId: team.id, + createTemplateOptions, + }); + + // Run test for personal and team templates. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + // Check that the direct template link is accessible. + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail()); + + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Complete' }).click(); + await page.getByRole('button', { name: 'Sign' }).click(); + await page.waitForURL(/\/sign/); + await expect(page.getByText('Waiting for others to sign')).toBeVisible(); + } + + await apiSignin({ + page, + email: owner.email, + }); + + // Check that the owner has the documents. + for (const template of [personalDirectTemplate, teamDirectTemplate]) { + await page.goto(`${WEBAPP_BASE_URL}${formatDocumentsPath(template.team?.url)}`); + + // Check that the document is in the 'All' tab. + await checkDocumentTabCount(page, 'All', 1); + await checkDocumentTabCount(page, 'Pending', 1); + } + + // Check that the second recipient has the 2 pending documents. + await apiSignin({ + page, + email: secondRecipient.email, + }); + + await page.goto('/documents'); + + await checkDocumentTabCount(page, 'All', 2); + await checkDocumentTabCount(page, 'Inbox', 2); + + await unseedTeam(team.url); + await unseedUser(secondRecipient.id); +}); diff --git a/packages/ee/server-only/limits/constants.ts b/packages/ee/server-only/limits/constants.ts index 4c428f34f..b3ca4c81b 100644 --- a/packages/ee/server-only/limits/constants.ts +++ b/packages/ee/server-only/limits/constants.ts @@ -3,14 +3,17 @@ import type { TLimitsSchema } from './schema'; export const FREE_PLAN_LIMITS: TLimitsSchema = { documents: 5, recipients: 10, + directTemplates: 3, }; export const TEAM_PLAN_LIMITS: TLimitsSchema = { documents: Infinity, recipients: Infinity, + directTemplates: Infinity, }; export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = { documents: Infinity, recipients: Infinity, + directTemplates: Infinity, }; diff --git a/packages/ee/server-only/limits/schema.ts b/packages/ee/server-only/limits/schema.ts index e3394995d..6583b826c 100644 --- a/packages/ee/server-only/limits/schema.ts +++ b/packages/ee/server-only/limits/schema.ts @@ -10,6 +10,10 @@ export const ZLimitsSchema = z.object({ .preprocess((v) => (v === null ? Infinity : Number(v)), z.number()) .optional() .default(0), + directTemplates: z + .preprocess((v) => (v === null ? Infinity : Number(v)), z.number()) + .optional() + .default(0), }); export type TLimitsSchema = z.infer; diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index abed86da7..ad079c95d 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -2,11 +2,12 @@ import { DateTime } from 'luxon'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { prisma } from '@documenso/prisma'; -import { SubscriptionStatus } from '@documenso/prisma/client'; +import { DocumentSource, SubscriptionStatus } from '@documenso/prisma/client'; import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts'; import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants'; import { ERROR_CODES } from './errors'; +import type { TLimitsResponseSchema } from './schema'; import { ZLimitsSchema } from './schema'; export type GetServerLimitsOptions = { @@ -14,7 +15,10 @@ export type GetServerLimitsOptions = { teamId?: number | null; }; -export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => { +export const getServerLimits = async ({ + email, + teamId, +}: GetServerLimitsOptions): Promise => { if (!IS_BILLING_ENABLED()) { return { quota: SELFHOSTED_PLAN_LIMITS, @@ -74,19 +78,37 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => { remaining = structuredClone(quota); } } + + // Assume all active subscriptions provide unlimited direct templates. + remaining.directTemplates = Infinity; } - const documents = await prisma.document.count({ - where: { - userId: user.id, - teamId: null, - createdAt: { - gte: DateTime.utc().startOf('month').toJSDate(), + const [documents, directTemplates] = await Promise.all([ + prisma.document.count({ + where: { + userId: user.id, + teamId: null, + createdAt: { + gte: DateTime.utc().startOf('month').toJSDate(), + }, + source: { + not: DocumentSource.TEMPLATE_DIRECT_LINK, + }, }, - }, - }); + }), + prisma.template.count({ + where: { + userId: user.id, + teamId: null, + directLink: { + isNot: null, + }, + }, + }), + ]); remaining.documents = Math.max(remaining.documents - documents, 0); + remaining.directTemplates = Math.max(remaining.directTemplates - directTemplates, 0); return { quota, @@ -127,10 +149,12 @@ const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => { quota: { documents: 0, recipients: 0, + directTemplates: 0, }, remaining: { documents: 0, recipients: 0, + directTemplates: 0, }, }; } diff --git a/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts b/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts index cda583e81..a2aac4f27 100644 --- a/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts +++ b/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts @@ -8,6 +8,7 @@ import { alphaid, nanoid } from '@documenso/lib/universal/id'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { prisma } from '@documenso/prisma'; import { + DocumentSource, DocumentStatus, FieldType, ReadStatus, @@ -86,6 +87,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko status: DocumentStatus.COMPLETED, userId: newUser.id, documentDataId, + source: DocumentSource.DOCUMENT, }, }); diff --git a/packages/email/templates/document-created-from-direct-template.tsx b/packages/email/templates/document-created-from-direct-template.tsx new file mode 100644 index 000000000..e1512d041 --- /dev/null +++ b/packages/email/templates/document-created-from-direct-template.tsx @@ -0,0 +1,93 @@ +import config from '@documenso/tailwind-config'; + +import { + Body, + Button, + Container, + Head, + Html, + Img, + Preview, + Section, + Tailwind, + Text, +} from '../components'; +import TemplateDocumentImage from '../template-components/template-document-image'; +import { TemplateFooter } from '../template-components/template-footer'; + +export type DocumentCompletedEmailTemplateProps = { + recipientName?: string; + documentLink?: string; + documentName?: string; + assetBaseUrl?: string; +}; + +export const DocumentCreatedFromDirectTemplateEmailTemplate = ({ + recipientName = 'John Doe', + documentLink = 'http://localhost:3000', + documentName = 'Open Source Pledge.pdf', + assetBaseUrl = 'http://localhost:3002', +}: DocumentCompletedEmailTemplateProps) => { + const previewText = `Completed Document`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {previewText} + + +
    + +
    + Documenso Logo + + + +
    + + {recipientName} signed a document by using one of your direct links + + +
    + {documentName} +
    + +
    + +
    +
    +
    +
    + + + + +
    + +
    + + ); +}; + +export default DocumentCreatedFromDirectTemplateEmailTemplate; diff --git a/packages/lib/constants/template.ts b/packages/lib/constants/template.ts index 061b9e594..029f3e26d 100644 --- a/packages/lib/constants/template.ts +++ b/packages/lib/constants/template.ts @@ -1,2 +1,28 @@ export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i; export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i; + +export const DIRECT_TEMPLATE_DOCUMENTATION = [ + { + title: 'Enable Direct Link Signing', + description: + 'Once enabled, you can select any active recipient to be a direct link signing recipient, or create a new one. This recipient type cannot be edited or deleted.', + }, + { + title: 'Configure Direct Recipient', + description: + 'Update the role and add fields as required for the direct recipient. The individual who uses the direct link will sign the document as the direct recipient.', + }, + { + title: 'Share the Link', + description: + 'Once your template is set up, share the link anywhere you want. The person who opens the link will be able to enter their information in the direct link recipient field and complete any other fields assigned to them.', + }, + { + title: 'Document Creation', + description: + 'After submission, a document will be automatically generated and added to your documents page. You will also receive a notification via email.', + }, +]; + +export const DIRECT_TEMPLATE_RECIPIENT_EMAIL = 'direct.link@documenso.com'; +export const DIRECT_TEMPLATE_RECIPIENT_NAME = 'Direct link recipient'; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index b48e45d54..de2883d93 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -12,6 +12,7 @@ export enum AppErrorCode { 'EXPIRED_CODE' = 'ExpiredCode', 'INVALID_BODY' = 'InvalidBody', 'INVALID_REQUEST' = 'InvalidRequest', + 'LIMIT_EXCEEDED' = 'LimitExceeded', 'NOT_FOUND' = 'NotFound', 'NOT_SETUP' = 'NotSetup', 'UNAUTHORIZED' = 'Unauthorized', diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index 1d145a60d..c4521f504 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -5,7 +5,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; -import { WebhookTriggerEvents } from '@documenso/prisma/client'; +import { DocumentSource, WebhookTriggerEvents } from '@documenso/prisma/client'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -54,6 +54,7 @@ export const createDocument = async ({ userId, teamId, formValues, + source: DocumentSource.DOCUMENT, }, }); @@ -65,6 +66,9 @@ export const createDocument = async ({ requestMetadata, data: { title, + source: { + type: DocumentSource.DOCUMENT, + }, }, }), }); 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 4e6a7bd87..febcda465 100644 --- a/packages/lib/server-only/document/duplicate-document-by-id.ts +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -1,5 +1,5 @@ import { prisma } from '@documenso/prisma'; -import type { Prisma } from '@documenso/prisma/client'; +import { DocumentSource, type Prisma } from '@documenso/prisma/client'; import { getDocumentWhereInput } from './get-document-by-id'; @@ -64,6 +64,7 @@ export const duplicateDocumentById = async ({ ...document.documentMeta, }, }, + source: DocumentSource.DOCUMENT, }, }; 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 6add46c1d..7f2d2172a 100644 --- a/packages/lib/server-only/document/get-document-by-token.ts +++ b/packages/lib/server-only/document/get-document-by-token.ts @@ -99,7 +99,7 @@ export const getDocumentAndSenderByToken = async ({ if (requireAccessAuth) { documentAccessValid = await isRecipientAuthorized({ type: 'ACCESS', - document: result, + documentAuthOptions: result.authOptions, recipient, userId, authOptions: accessAuth, @@ -159,7 +159,7 @@ export const getDocumentAndRecipientByToken = async ({ if (requireAccessAuth) { documentAccessValid = await isRecipientAuthorized({ type: 'ACCESS', - document: result, + documentAuthOptions: result.authOptions, recipient, userId, authOptions: accessAuth, diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts index 5da50d6c7..151235fe2 100644 --- a/packages/lib/server-only/document/is-recipient-authorized.ts +++ b/packages/lib/server-only/document/is-recipient-authorized.ts @@ -14,8 +14,8 @@ import { extractDocumentAuthMethods } from '../../utils/document-auth'; type IsRecipientAuthorizedOptions = { type: 'ACCESS' | 'ACTION'; - document: Document; - recipient: Recipient; + documentAuthOptions: Document['authOptions']; + recipient: Pick; /** * The ID of the user who initiated the request. @@ -50,13 +50,13 @@ const getUserByEmail = async (email: string) => { */ export const isRecipientAuthorized = async ({ type, - document, + documentAuthOptions, recipient, userId, authOptions, }: IsRecipientAuthorizedOptions): Promise => { const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({ - documentAuth: document.authOptions, + documentAuth: documentAuthOptions, recipientAuth: recipient.authOptions, }); diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index fc65e8c6e..3697f88fc 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -12,7 +12,13 @@ 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'; -import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; +import { + DocumentSource, + DocumentStatus, + RecipientRole, + SendStatus, + SigningStatus, +} from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; @@ -92,6 +98,8 @@ export const sendDocument = async ({ const { documentData } = document; + const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK; + if (!documentData.data) { throw new Error('Document data not found'); } @@ -133,10 +141,21 @@ export const sendDocument = async ({ const { email, name } = recipient; const selfSigner = email === user.email; + const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; + const recipientActionVerb = actionVerb.toLowerCase(); - const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[ - recipient.role - ].actionVerb.toLowerCase()} it.`; + let emailMessage = customEmail?.message || ''; + let emailSubject = `Please ${recipientActionVerb} this document`; + + if (selfSigner) { + emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`; + emailSubject = `Please ${recipientActionVerb} your document`; + } + + if (isDirectTemplate) { + emailMessage = `A document was created by your direct template that requires you to ${recipientActionVerb} it.`; + emailSubject = `Please ${recipientActionVerb} this document created by your direct template`; + } const customEmailTemplate = { 'signer.name': name, @@ -153,22 +172,11 @@ export const sendDocument = async ({ inviterEmail: user.email, assetBaseUrl, signDocumentLink, - customBody: renderCustomEmailTemplate( - selfSigner && !customEmail?.message - ? selfSignerCustomEmail - : customEmail?.message || '', - customEmailTemplate, - ), + customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate), role: recipient.role, selfSigner, }); - const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; - - const emailSubject = selfSigner - ? `Please ${actionVerb.toLowerCase()} your document` - : `Please ${actionVerb.toLowerCase()} this document`; - await prisma.$transaction( async (tx) => { await mailer.sendMail({ @@ -220,7 +228,8 @@ export const sendDocument = async ({ } const allRecipientsHaveNoActionToTake = document.Recipient.every( - (recipient) => recipient.role === RecipientRole.CC, + (recipient) => + recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED, ); if (allRecipientsHaveNoActionToTake) { diff --git a/packages/lib/server-only/document/validate-field-auth.ts b/packages/lib/server-only/document/validate-field-auth.ts new file mode 100644 index 000000000..8dd140395 --- /dev/null +++ b/packages/lib/server-only/document/validate-field-auth.ts @@ -0,0 +1,52 @@ +import type { Document, Field, Recipient } from '@documenso/prisma/client'; +import { FieldType } from '@documenso/prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import type { TRecipientActionAuth } from '../../types/document-auth'; +import { extractDocumentAuthMethods } from '../../utils/document-auth'; +import { isRecipientAuthorized } from './is-recipient-authorized'; + +export type ValidateFieldAuthOptions = { + documentAuthOptions: Document['authOptions']; + recipient: Pick; + field: Field; + userId?: number; + authOptions?: TRecipientActionAuth; +}; + +/** + * Throws an error if the reauth for a field is invalid. + * + * Returns the derived recipient action authentication if valid. + */ +export const validateFieldAuth = async ({ + documentAuthOptions, + recipient, + field, + userId, + authOptions, +}: ValidateFieldAuthOptions) => { + const { derivedRecipientActionAuth } = extractDocumentAuthMethods({ + documentAuth: documentAuthOptions, + recipientAuth: recipient.authOptions, + }); + + // Override all non-signature fields to not require any auth. + if (field.type !== FieldType.SIGNATURE) { + return null; + } + + const isValid = await isRecipientAuthorized({ + type: 'ACTION', + documentAuthOptions, + recipient, + userId, + authOptions, + }); + + if (!isValid) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); + } + + return derivedRecipientActionAuth; +}; diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index 359a5da68..6f7bbb029 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -8,13 +8,11 @@ import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/clie import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; -import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import type { TRecipientActionAuth } from '../../types/document-auth'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; -import { extractDocumentAuthMethods } from '../../utils/document-auth'; -import { isRecipientAuthorized } from '../document/is-recipient-authorized'; +import { validateFieldAuth } from '../document/validate-field-auth'; export type SignFieldWithTokenOptions = { token: string; @@ -26,6 +24,16 @@ export type SignFieldWithTokenOptions = { requestMetadata?: RequestMetadata; }; +/** + * Please read. + * + * Content within this function has been duplicated in the + * createDocumentFromDirectTemplate file. + * + * Any update to this should be reflected in the other file if required. + * + * Todo: Extract common logic. + */ export const signFieldWithToken = async ({ token, fieldId, @@ -79,33 +87,14 @@ export const signFieldWithToken = async ({ throw new Error(`Field ${fieldId} has no recipientId`); } - let { derivedRecipientActionAuth } = extractDocumentAuthMethods({ - documentAuth: document.authOptions, - recipientAuth: recipient.authOptions, + const derivedRecipientActionAuth = await validateFieldAuth({ + documentAuthOptions: document.authOptions, + recipient, + field, + userId, + authOptions, }); - // Override all non-signature fields to not require any auth. - if (field.type !== FieldType.SIGNATURE) { - derivedRecipientActionAuth = null; - } - - let isValid = true; - - // Only require auth on signature fields for now. - if (field.type === FieldType.SIGNATURE) { - isValid = await isRecipientAuthorized({ - type: 'ACTION', - document: document, - recipient: recipient, - userId, - authOptions, - }); - } - - if (!isValid) { - throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); - } - const documentMeta = await prisma.documentMeta.findFirst({ where: { documentId: document.id, @@ -142,10 +131,6 @@ export const signFieldWithToken = async ({ }); if (isSignatureField) { - if (!field.recipientId) { - throw new Error('Field has no recipientId'); - } - const signature = await tx.signature.upsert({ where: { fieldId: field.id, diff --git a/packages/lib/server-only/recipient/set-recipients-for-template.ts b/packages/lib/server-only/recipient/set-recipients-for-template.ts index 73d05ab4e..c76a4a5ef 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-template.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-template.ts @@ -3,6 +3,10 @@ import { prisma } from '@documenso/prisma'; import type { Recipient } from '@documenso/prisma/client'; import { RecipientRole } from '@documenso/prisma/client'; +import { + DIRECT_TEMPLATE_RECIPIENT_EMAIL, + DIRECT_TEMPLATE_RECIPIENT_NAME, +} from '../../constants/template'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { type TRecipientActionAuthTypes, @@ -48,6 +52,9 @@ export const setRecipientsForTemplate = async ({ }, ], }, + include: { + directLink: true, + }, }); if (!template) { @@ -71,10 +78,21 @@ export const setRecipientsForTemplate = async ({ } } - const normalizedRecipients = recipients.map((recipient) => ({ - ...recipient, - email: recipient.email.toLowerCase(), - })); + const normalizedRecipients = recipients.map((recipient) => { + // Force replace any changes to the name or email of the direct recipient. + if (template.directLink && recipient.id === template.directLink.directTemplateRecipientId) { + return { + ...recipient, + email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, + name: DIRECT_TEMPLATE_RECIPIENT_NAME, + }; + } + + return { + ...recipient, + email: recipient.email.toLowerCase(), + }; + }); const existingRecipients = await prisma.recipient.findMany({ where: { @@ -90,6 +108,27 @@ export const setRecipientsForTemplate = async ({ ), ); + if (template.directLink !== null) { + const updatedDirectRecipient = recipients.find( + (recipient) => recipient.id === template.directLink?.directTemplateRecipientId, + ); + + const deletedDirectRecipient = removedRecipients.find( + (recipient) => recipient.id === template.directLink?.directTemplateRecipientId, + ); + + if (updatedDirectRecipient?.role === RecipientRole.CC) { + throw new AppError(AppErrorCode.INVALID_BODY, 'Cannot set direct recipient as CC'); + } + + if (deletedDirectRecipient) { + throw new AppError( + AppErrorCode.INVALID_BODY, + 'Cannot delete direct recipient while direct template exists', + ); + } + } + const linkedRecipients = normalizedRecipients.map((recipient) => { const existing = existingRecipients.find( (existingRecipient) => diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts new file mode 100644 index 000000000..eeb639bb8 --- /dev/null +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -0,0 +1,526 @@ +import { createElement } from 'react'; + +import { DateTime } from 'luxon'; +import { match } from 'ts-pattern'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template'; +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import type { Field, Signature } from '@documenso/prisma/client'; +import { + DocumentSource, + DocumentStatus, + FieldType, + RecipientRole, + SendStatus, + SigningStatus, +} from '@documenso/prisma/client'; +import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; +import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import type { TRecipientActionAuthTypes } from '../../types/document-auth'; +import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth'; +import type { RequestMetadata } from '../../universal/extract-request-metadata'; +import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs'; +import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { + createDocumentAuthOptions, + createRecipientAuthOptions, + extractDocumentAuthMethods, +} from '../../utils/document-auth'; +import { formatDocumentsPath } from '../../utils/teams'; +import { sendDocument } from '../document/send-document'; +import { validateFieldAuth } from '../document/validate-field-auth'; + +export type CreateDocumentFromDirectTemplateOptions = { + directRecipientEmail: string; + directTemplateToken: string; + signedFieldValues: TSignFieldWithTokenMutationSchema[]; + templateUpdatedAt: Date; + requestMetadata: RequestMetadata; + user?: { + id: number; + name?: string; + email: string; + }; +}; + +type CreatedDirectRecipientField = { + field: Field & { Signature?: Signature | null }; + derivedRecipientActionAuth: TRecipientActionAuthTypes | null; +}; + +export const createDocumentFromDirectTemplate = async ({ + directRecipientEmail, + directTemplateToken, + signedFieldValues, + templateUpdatedAt, + requestMetadata, + user, +}: CreateDocumentFromDirectTemplateOptions) => { + const template = await prisma.template.findFirst({ + where: { + directLink: { + token: directTemplateToken, + }, + }, + include: { + Recipient: { + include: { + Field: true, + }, + }, + directLink: true, + templateDocumentData: true, + templateMeta: true, + User: true, + }, + }); + + if (!template?.directLink?.enabled) { + throw new AppError(AppErrorCode.INVALID_REQUEST, 'Invalid or missing template'); + } + + const { Recipient: recipients, directLink, User: templateOwner } = template; + + const directTemplateRecipient = recipients.find( + (recipient) => recipient.id === directLink.directTemplateRecipientId, + ); + + if (!directTemplateRecipient || directTemplateRecipient.role === RecipientRole.CC) { + throw new AppError(AppErrorCode.INVALID_REQUEST, 'Invalid or missing direct recipient'); + } + + if (template.updatedAt.getTime() !== templateUpdatedAt.getTime()) { + throw new AppError(AppErrorCode.INVALID_REQUEST, 'Template no longer matches'); + } + + if (user && user.email !== directRecipientEmail) { + throw new AppError(AppErrorCode.INVALID_REQUEST, 'Email must match if you are logged in'); + } + + const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } = + extractDocumentAuthMethods({ + documentAuth: template.authOptions, + }); + + const directRecipientName = user?.name; + + // Ensure typesafety when we add more options. + const isAccessAuthValid = match(derivedRecipientAccessAuth) + .with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail) + .with(null, () => true) + .exhaustive(); + + if (!isAccessAuthValid) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'You must be logged in'); + } + + const directTemplateRecipientAuthOptions = ZRecipientAuthOptionsSchema.parse( + directTemplateRecipient.authOptions, + ); + + const nonDirectTemplateRecipients = template.Recipient.filter( + (recipient) => recipient.id !== directTemplateRecipient.id, + ); + + const metaTimezone = template.templateMeta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE; + const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT; + + // Associate, validate and map to a query every direct template recipient field with the provided fields. + const createDirectRecipientFieldArgs = await Promise.all( + directTemplateRecipient.Field.map(async (templateField) => { + const signedFieldValue = signedFieldValues.find( + (value) => value.fieldId === templateField.id, + ); + + if (!signedFieldValue) { + throw new AppError(AppErrorCode.INVALID_BODY, 'Invalid, missing or changed fields'); + } + + if (templateField.type === FieldType.NAME && directRecipientName === undefined) { + directRecipientName === signedFieldValue.value; + } + + const derivedRecipientActionAuth = await validateFieldAuth({ + documentAuthOptions: template.authOptions, + recipient: { + authOptions: directTemplateRecipient.authOptions, + email: directRecipientEmail, + }, + field: templateField, + userId: user?.id, + authOptions: signedFieldValue.authOptions, + }); + + const { value, isBase64 } = signedFieldValue; + + const isSignatureField = + templateField.type === FieldType.SIGNATURE || + templateField.type === FieldType.FREE_SIGNATURE; + + let customText = !isSignatureField ? value : ''; + + const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined; + const typedSignature = isSignatureField && !isBase64 ? value : undefined; + + if (templateField.type === FieldType.DATE) { + customText = DateTime.now().setZone(metaTimezone).toFormat(metaDateFormat); + } + + if (isSignatureField && !signatureImageAsBase64 && !typedSignature) { + throw new Error('Signature field must have a signature'); + } + + return { + templateField, + customText, + derivedRecipientActionAuth, + signature: isSignatureField + ? { + signatureImageAsBase64, + typedSignature, + } + : null, + }; + }), + ); + + const directTemplateNonSignatureFields = createDirectRecipientFieldArgs.filter( + ({ signature }) => signature === null, + ); + + const directTemplateSignatureFields = createDirectRecipientFieldArgs.filter( + ({ signature }) => signature !== null, + ); + + const initialRequestTime = new Date(); + + const { documentId, directRecipientToken } = await prisma.$transaction(async (tx) => { + const documentData = await tx.documentData.create({ + data: { + type: template.templateDocumentData.type, + data: template.templateDocumentData.data, + initialData: template.templateDocumentData.initialData, + }, + }); + + // Create the document and non direct template recipients. + const document = await tx.document.create({ + data: { + source: DocumentSource.TEMPLATE_DIRECT_LINK, + templateId: template.id, + userId: template.userId, + teamId: template.teamId, + title: template.title, + createdAt: initialRequestTime, + status: DocumentStatus.PENDING, + documentDataId: documentData.id, + authOptions: createDocumentAuthOptions({ + globalAccessAuth: templateAuthOptions.globalAccessAuth, + globalActionAuth: templateAuthOptions.globalActionAuth, + }), + Recipient: { + createMany: { + data: nonDirectTemplateRecipients.map((recipient) => { + const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions); + + return { + email: recipient.email, + name: recipient.name, + role: recipient.role, + authOptions: createRecipientAuthOptions({ + accessAuth: authOptions.accessAuth, + actionAuth: authOptions.actionAuth, + }), + sendStatus: + recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + recipient.role === RecipientRole.CC + ? SigningStatus.SIGNED + : SigningStatus.NOT_SIGNED, + token: nanoid(), + }; + }), + }, + }, + }, + include: { + Recipient: true, + team: { + select: { + url: true, + }, + }, + }, + }); + + let nonDirectRecipientFieldsToCreate: Omit[] = []; + + Object.values(nonDirectTemplateRecipients).forEach((templateRecipient) => { + const recipient = document.Recipient.find( + (recipient) => recipient.email === templateRecipient.email, + ); + + if (!recipient) { + throw new Error('Recipient not found.'); + } + + nonDirectRecipientFieldsToCreate = nonDirectRecipientFieldsToCreate.concat( + templateRecipient.Field.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: nonDirectRecipientFieldsToCreate, + }); + + // Create the direct recipient and their non signature fields. + const createdDirectRecipient = await tx.recipient.create({ + data: { + documentId: document.id, + email: directRecipientEmail, + name: directRecipientName, + authOptions: createRecipientAuthOptions({ + accessAuth: directTemplateRecipientAuthOptions.accessAuth, + actionAuth: directTemplateRecipientAuthOptions.actionAuth, + }), + role: directTemplateRecipient.role, + token: nanoid(), + signingStatus: SigningStatus.SIGNED, + sendStatus: SendStatus.SENT, + signedAt: initialRequestTime, + Field: { + createMany: { + data: directTemplateNonSignatureFields.map(({ templateField, customText }) => ({ + documentId: document.id, + type: templateField.type, + page: templateField.page, + positionX: templateField.positionX, + positionY: templateField.positionY, + width: templateField.width, + height: templateField.height, + customText, + inserted: true, + })), + }, + }, + }, + include: { + Field: true, + }, + }); + + // Create any direct recipient signature fields. + // Note: It's done like this because we can't nest things in createMany. + const createdDirectRecipientSignatureFields: CreatedDirectRecipientField[] = await Promise.all( + directTemplateSignatureFields.map( + async ({ templateField, signature, derivedRecipientActionAuth }) => { + if (!signature) { + throw new Error('Not possible.'); + } + + const field = await tx.field.create({ + data: { + documentId: document.id, + recipientId: createdDirectRecipient.id, + type: templateField.type, + page: templateField.page, + positionX: templateField.positionX, + positionY: templateField.positionY, + width: templateField.width, + height: templateField.height, + customText: '', + inserted: true, + Signature: { + create: { + recipientId: createdDirectRecipient.id, + signatureImageAsBase64: signature.signatureImageAsBase64, + typedSignature: signature.typedSignature, + }, + }, + }, + include: { + Signature: true, + }, + }); + + return { + field, + derivedRecipientActionAuth, + }; + }, + ), + ); + + const createdDirectRecipientFields: CreatedDirectRecipientField[] = [ + ...createdDirectRecipient.Field.map((field) => ({ + field, + derivedRecipientActionAuth: null, + })), + ...createdDirectRecipientSignatureFields, + ]; + + /** + * Create the following audit logs. + * - DOCUMENT_CREATED + * - DOCUMENT_FIELD_INSERTED + * - DOCUMENT_RECIPIENT_COMPLETED + */ + const auditLogsToCreate: CreateDocumentAuditLogDataResponse[] = [ + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, + documentId: document.id, + user: { + id: user?.id, + name: user?.name, + email: directRecipientEmail, + }, + requestMetadata, + data: { + title: document.title, + source: { + type: DocumentSource.TEMPLATE_DIRECT_LINK, + templateId: template.id, + directRecipientEmail, + }, + }, + }), + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, + documentId: document.id, + user: { + id: user?.id, + name: user?.name, + email: directRecipientEmail, + }, + requestMetadata, + data: { + recipientEmail: createdDirectRecipient.email, + recipientId: createdDirectRecipient.id, + recipientName: createdDirectRecipient.name, + recipientRole: createdDirectRecipient.role, + accessAuth: derivedRecipientAccessAuth || undefined, + }, + }), + ...createdDirectRecipientFields.map(({ field, derivedRecipientActionAuth }) => + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, + documentId: document.id, + user: { + id: user?.id, + name: user?.name, + email: directRecipientEmail, + }, + requestMetadata, + data: { + recipientEmail: createdDirectRecipient.email, + recipientId: createdDirectRecipient.id, + recipientName: createdDirectRecipient.name, + recipientRole: createdDirectRecipient.role, + fieldId: field.secondaryId, + field: match(field.type) + .with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, (type) => ({ + type, + data: + field.Signature?.signatureImageAsBase64 || field.Signature?.typedSignature || '', + })) + .with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({ + type, + data: field.customText, + })) + .exhaustive(), + fieldSecurity: derivedRecipientActionAuth + ? { + type: derivedRecipientActionAuth, + } + : undefined, + }, + }), + ), + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, + documentId: document.id, + user: { + id: user?.id, + name: user?.name, + email: directRecipientEmail, + }, + requestMetadata, + data: { + recipientEmail: createdDirectRecipient.email, + recipientId: createdDirectRecipient.id, + recipientName: createdDirectRecipient.name, + recipientRole: createdDirectRecipient.role, + }, + }), + ]; + + await tx.documentAuditLog.createMany({ + data: auditLogsToCreate, + }); + + // Send email to template owner. + const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, { + recipientName: directRecipientEmail, + documentLink: `${formatDocumentsPath(document.team?.url)}/${document.id}`, + documentName: document.title, + assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000', + }); + + await mailer.sendMail({ + to: [ + { + name: templateOwner.name || '', + address: templateOwner.email, + }, + ], + from: { + name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso', + address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com', + }, + subject: 'Document created from direct template', + html: render(emailTemplate), + text: render(emailTemplate, { plainText: true }), + }); + + return { + documentId: document.id, + directRecipientToken: createdDirectRecipient.token, + }; + }); + + try { + // This handles sending emails and sealing the document if required. + await sendDocument({ + documentId, + userId: template.userId, + teamId: template.teamId || undefined, + requestMetadata, + }); + } catch (err) { + console.error('[CREATE_DOCUMENT_FROM_DIRECT_TEMPLATE]:', err); + + // Don't launch an error since the document has already been created. + // Log and reseal as required until we configure middleware. + } + + return directRecipientToken; +}; diff --git a/packages/lib/server-only/template/create-document-from-template-legacy.ts b/packages/lib/server-only/template/create-document-from-template-legacy.ts index fadbae4c3..b1ecd3913 100644 --- a/packages/lib/server-only/template/create-document-from-template-legacy.ts +++ b/packages/lib/server-only/template/create-document-from-template-legacy.ts @@ -1,6 +1,6 @@ import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; -import type { RecipientRole } from '@documenso/prisma/client'; +import { DocumentSource, type RecipientRole } from '@documenso/prisma/client'; export type CreateDocumentFromTemplateLegacyOptions = { templateId: number; @@ -62,6 +62,8 @@ export const createDocumentFromTemplateLegacy = async ({ const document = await prisma.document.create({ data: { + source: DocumentSource.TEMPLATE, + templateId: template.id, userId, teamId: template.teamId, title: template.title, diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 92590cfb2..da864e367 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -1,7 +1,14 @@ import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; import type { Field } from '@documenso/prisma/client'; -import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client'; +import { + DocumentSource, + type Recipient, + RecipientRole, + SendStatus, + SigningStatus, + WebhookTriggerEvents, +} from '@documenso/prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; @@ -139,6 +146,8 @@ export const createDocumentFromTemplate = async ({ return await prisma.$transaction(async (tx) => { const document = await tx.document.create({ data: { + source: DocumentSource.TEMPLATE, + templateId: template.id, userId, teamId: template.teamId, title: override?.title || template.title, @@ -170,6 +179,12 @@ export const createDocumentFromTemplate = async ({ accessAuth: authOptions.accessAuth, actionAuth: authOptions.actionAuth, }), + sendStatus: + recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + recipient.role === RecipientRole.CC + ? SigningStatus.SIGNED + : SigningStatus.NOT_SIGNED, token: nanoid(), }; }), @@ -223,6 +238,10 @@ export const createDocumentFromTemplate = async ({ requestMetadata, data: { title: document.title, + source: { + type: DocumentSource.TEMPLATE, + templateId: template.id, + }, }, }), }); diff --git a/packages/lib/server-only/template/create-template-direct-link.ts b/packages/lib/server-only/template/create-template-direct-link.ts new file mode 100644 index 000000000..1ea2dc2ba --- /dev/null +++ b/packages/lib/server-only/template/create-template-direct-link.ts @@ -0,0 +1,107 @@ +'use server'; + +import { nanoid } from 'nanoid'; + +import { prisma } from '@documenso/prisma'; +import type { Recipient, TemplateDirectLink } from '@documenso/prisma/client'; + +import { + DIRECT_TEMPLATE_RECIPIENT_EMAIL, + DIRECT_TEMPLATE_RECIPIENT_NAME, +} from '../../constants/template'; +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export type CreateTemplateDirectLinkOptions = { + templateId: number; + userId: number; + directRecipientId?: number; +}; + +export const createTemplateDirectLink = async ({ + templateId, + userId, + directRecipientId, +}: CreateTemplateDirectLinkOptions): Promise => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + include: { + Recipient: true, + directLink: true, + }, + }); + + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found'); + } + + if (template.directLink) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Direct template already exists'); + } + + if ( + directRecipientId && + !template.Recipient.find((recipient) => recipient.id === directRecipientId) + ) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Recipient not found'); + } + + if ( + !directRecipientId && + template.Recipient.find( + (recipient) => recipient.email.toLowerCase() === DIRECT_TEMPLATE_RECIPIENT_EMAIL, + ) + ) { + throw new AppError(AppErrorCode.INVALID_BODY, 'Cannot generate placeholder direct recipient'); + } + + return await prisma.$transaction(async (tx) => { + let recipient: Recipient | undefined; + + if (directRecipientId) { + recipient = await tx.recipient.update({ + where: { + templateId, + id: directRecipientId, + }, + data: { + name: DIRECT_TEMPLATE_RECIPIENT_NAME, + email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, + }, + }); + } else { + recipient = await tx.recipient.create({ + data: { + templateId, + name: DIRECT_TEMPLATE_RECIPIENT_NAME, + email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, + token: nanoid(), + }, + }); + } + + return await tx.templateDirectLink.create({ + data: { + templateId, + enabled: true, + token: nanoid(), + directTemplateRecipientId: recipient.id, + }, + }); + }); +}; diff --git a/packages/lib/server-only/template/delete-template-direct-link.ts b/packages/lib/server-only/template/delete-template-direct-link.ts new file mode 100644 index 000000000..bb4e575d9 --- /dev/null +++ b/packages/lib/server-only/template/delete-template-direct-link.ts @@ -0,0 +1,68 @@ +'use server'; + +import { generateAvaliableRecipientPlaceholder } from '@documenso/lib/utils/templates'; +import { prisma } from '@documenso/prisma'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export type DeleteTemplateDirectLinkOptions = { + templateId: number; + userId: number; +}; + +export const deleteTemplateDirectLink = async ({ + templateId, + userId, +}: DeleteTemplateDirectLinkOptions): Promise => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + include: { + directLink: true, + Recipient: true, + }, + }); + + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found'); + } + + const { directLink } = template; + + if (!directLink) { + return; + } + + await prisma.$transaction(async (tx) => { + await tx.recipient.update({ + where: { + templateId: template.id, + id: directLink.directTemplateRecipientId, + }, + data: { + ...generateAvaliableRecipientPlaceholder(template.Recipient), + }, + }); + + await tx.templateDirectLink.delete({ + where: { + templateId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts index 9252d32ea..d5d38adf1 100644 --- a/packages/lib/server-only/template/find-templates.ts +++ b/packages/lib/server-only/template/find-templates.ts @@ -8,6 +8,9 @@ export type FindTemplatesOptions = { perPage: number; }; +export type FindTemplatesResponse = Awaited>; +export type FindTemplateRow = FindTemplatesResponse['templates'][number]; + export const findTemplates = async ({ userId, teamId, @@ -45,6 +48,12 @@ export const findTemplates = async ({ }, Field: true, Recipient: true, + directLink: { + select: { + token: true, + enabled: true, + }, + }, }, skip: Math.max(page - 1, 0) * perPage, orderBy: { diff --git a/packages/lib/server-only/template/get-template-by-direct-link-token.ts b/packages/lib/server-only/template/get-template-by-direct-link-token.ts new file mode 100644 index 000000000..49d518468 --- /dev/null +++ b/packages/lib/server-only/template/get-template-by-direct-link-token.ts @@ -0,0 +1,33 @@ +import { prisma } from '@documenso/prisma'; + +export interface GetTemplateByDirectLinkTokenOptions { + token: string; +} + +export const getTemplateByDirectLinkToken = async ({ + token, +}: GetTemplateByDirectLinkTokenOptions) => { + const template = await prisma.template.findFirstOrThrow({ + where: { + directLink: { + token, + enabled: true, + }, + }, + include: { + directLink: true, + Recipient: { + include: { + Field: true, + }, + }, + templateDocumentData: true, + templateMeta: true, + }, + }); + + return { + ...template, + Field: template.Recipient.map((recipient) => recipient.Field).flat(), + }; +}; diff --git a/packages/lib/server-only/template/get-template-with-details-by-id.ts b/packages/lib/server-only/template/get-template-with-details-by-id.ts index 7d02c87cf..791295298 100644 --- a/packages/lib/server-only/template/get-template-with-details-by-id.ts +++ b/packages/lib/server-only/template/get-template-with-details-by-id.ts @@ -29,6 +29,7 @@ export const getTemplateWithDetailsById = async ({ ], }, include: { + directLink: true, templateDocumentData: true, templateMeta: true, Recipient: true, diff --git a/packages/lib/server-only/template/toggle-template-direct-link.ts b/packages/lib/server-only/template/toggle-template-direct-link.ts new file mode 100644 index 000000000..47c414d71 --- /dev/null +++ b/packages/lib/server-only/template/toggle-template-direct-link.ts @@ -0,0 +1,61 @@ +'use server'; + +import { prisma } from '@documenso/prisma'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export type ToggleTemplateDirectLinkOptions = { + templateId: number; + userId: number; + enabled: boolean; +}; + +export const toggleTemplateDirectLink = async ({ + templateId, + userId, + enabled, +}: ToggleTemplateDirectLinkOptions) => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], + }, + include: { + Recipient: true, + directLink: true, + }, + }); + + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found'); + } + + const { directLink } = template; + + if (!directLink) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Direct template link not found'); + } + + return await prisma.templateDirectLink.update({ + where: { + id: directLink.id, + }, + data: { + templateId: template.id, + enabled, + }, + }); +}; diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index cfdedd462..602396b3a 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -6,7 +6,7 @@ ///////////////////////////////////////////////////////////////////////////////////////////// import { z } from 'zod'; -import { FieldType } from '@documenso/prisma/client'; +import { DocumentSource, FieldType } from '@documenso/prisma/client'; import { ZRecipientActionAuthTypesSchema } from './document-auth'; @@ -192,6 +192,22 @@ export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED), data: z.object({ title: z.string(), + source: z + .union([ + z.object({ + type: z.literal(DocumentSource.DOCUMENT), + }), + z.object({ + type: z.literal(DocumentSource.TEMPLATE), + templateId: z.number(), + }), + z.object({ + type: z.literal(DocumentSource.TEMPLATE_DIRECT_LINK), + templateId: z.number(), + directRecipientEmail: z.string().email(), + }), + ]) + .optional(), }), }); diff --git a/packages/lib/utils/templates.ts b/packages/lib/utils/templates.ts new file mode 100644 index 000000000..8573c7e49 --- /dev/null +++ b/packages/lib/utils/templates.ts @@ -0,0 +1,44 @@ +import type { Recipient } from '@documenso/prisma/client'; + +import { WEBAPP_BASE_URL } from '../constants/app'; + +export const formatDirectTemplatePath = (token: string) => { + return `${WEBAPP_BASE_URL}/d/${token}`; +}; + +/** + * Generate a placeholder recipient using an index number. + * + * May collide with existing recipients. + * + * Note: + * - Update TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX if this is ever changed. + * - Update TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX if this is ever changed. + * + */ +export const generateRecipientPlaceholder = (index: number) => { + return { + name: `Recipient ${index}`, + email: `recipient.${index}@documenso.com`, + }; +}; + +/** + * Generates a placeholder that does not collide with any existing recipients. + * + * @param currentRecipients The current recipients that exist for a template. + */ +export const generateAvaliableRecipientPlaceholder = (currentRecipients: Recipient[]) => { + const recipientEmails = currentRecipients.map((recipient) => recipient.email); + let recipientPlaceholder = generateRecipientPlaceholder(0); + + for (let i = 1; i <= currentRecipients.length + 1; i++) { + recipientPlaceholder = generateRecipientPlaceholder(i); + + if (!recipientEmails.includes(recipientPlaceholder.email)) { + return recipientPlaceholder; + } + } + + return recipientPlaceholder; +}; diff --git a/packages/prisma/migrations/20240530055436_add_template_direct_links/migration.sql b/packages/prisma/migrations/20240530055436_add_template_direct_links/migration.sql new file mode 100644 index 000000000..5e6f5e213 --- /dev/null +++ b/packages/prisma/migrations/20240530055436_add_template_direct_links/migration.sql @@ -0,0 +1,45 @@ +/* + Warnings: + + - Added the required column `source` to the `Document` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "DocumentSource" AS ENUM ('DOCUMENT', 'TEMPLATE', 'TEMPLATE_DIRECT_LINK'); + +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "source" "DocumentSource", +ADD COLUMN "templateId" INTEGER; + +-- Custom: UpdateTable +UPDATE "Document" SET "source" = 'DOCUMENT' WHERE "source" IS NULL; + +-- Custom: AlterColumn +ALTER TABLE "Document" ALTER COLUMN "source" SET NOT NULL; + +-- CreateTable +CREATE TABLE "TemplateDirectLink" ( + "id" TEXT NOT NULL, + "templateId" INTEGER NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "enabled" BOOLEAN NOT NULL, + "directTemplateRecipientId" INTEGER NOT NULL, + + CONSTRAINT "TemplateDirectLink_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TemplateDirectLink_id_key" ON "TemplateDirectLink"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "TemplateDirectLink_templateId_key" ON "TemplateDirectLink"("templateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TemplateDirectLink_token_key" ON "TemplateDirectLink"("token"); + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TemplateDirectLink" ADD CONSTRAINT "TemplateDirectLink_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 908bb10c1..cb1456d2b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -261,6 +261,12 @@ enum DocumentStatus { COMPLETED } +enum DocumentSource { + DOCUMENT + TEMPLATE + TEMPLATE_DIRECT_LINK +} + model Document { id Int @id @default(autoincrement()) userId Int @@ -281,6 +287,9 @@ model Document { deletedAt DateTime? teamId Int? team Team? @relation(fields: [teamId], references: [id]) + templateId Int? + template Template? @relation(fields: [templateId], references: [id], onDelete: SetNull) + source DocumentSource auditLogs DocumentAuditLog[] @@ -572,15 +581,29 @@ model Template { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) - templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) - User User @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) Recipient Recipient[] Field Field[] + directLink TemplateDirectLink? + documents Document[] @@unique([templateDocumentDataId]) } +model TemplateDirectLink { + id String @id @unique @default(cuid()) + templateId Int @unique + token String @unique + createdAt DateTime @default(now()) + enabled Boolean + + directTemplateRecipientId Int + + template Template @relation(fields: [templateId], references: [id], onDelete: Cascade) +} + model SiteSettings { id String @id enabled Boolean @default(false) diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 2e6462daa..738cc8a04 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -7,6 +7,7 @@ import { match } from 'ts-pattern'; import { prisma } from '..'; import { DocumentDataType, + DocumentSource, DocumentStatus, FieldType, Prisma, @@ -68,6 +69,7 @@ export const seedBlankDocument = async (owner: User, options: CreateDocumentOpti return await prisma.document.create({ data: { + source: DocumentSource.DOCUMENT, title: `[TEST] Document ${key} - Draft`, status: DocumentStatus.DRAFT, documentDataId: documentData.id, @@ -102,6 +104,7 @@ export const seedDraftDocument = async ( const document = await prisma.document.create({ data: { + source: DocumentSource.DOCUMENT, title: `[TEST] Document ${key} - Draft`, status: DocumentStatus.DRAFT, documentDataId: documentData.id, @@ -170,6 +173,7 @@ export const seedPendingDocument = async ( const document = await prisma.document.create({ data: { + source: DocumentSource.DOCUMENT, title: `[TEST] Document ${key} - Pending`, status: DocumentStatus.PENDING, documentDataId: documentData.id, @@ -375,6 +379,7 @@ export const seedCompletedDocument = async ( const document = await prisma.document.create({ data: { + source: DocumentSource.DOCUMENT, title: `[TEST] Document ${key} - Completed`, status: DocumentStatus.COMPLETED, documentDataId: documentData.id, diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts index 6409c5bd9..66c944c9b 100644 --- a/packages/prisma/seed/initial-seed.ts +++ b/packages/prisma/seed/initial-seed.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { prisma } from '..'; -import { DocumentDataType, Role } from '../client'; +import { DocumentDataType, DocumentSource, Role } from '../client'; export const seedDatabase = async () => { const examplePdf = fs @@ -54,6 +54,7 @@ export const seedDatabase = async () => { await prisma.document.create({ data: { + source: DocumentSource.DOCUMENT, title: 'Example Document', documentDataId: examplePdfData.id, userId: exampleUser.id, diff --git a/packages/prisma/seed/templates.ts b/packages/prisma/seed/templates.ts index f37306c87..861b26bd4 100644 --- a/packages/prisma/seed/templates.ts +++ b/packages/prisma/seed/templates.ts @@ -1,6 +1,11 @@ import fs from 'node:fs'; import path from 'node:path'; +import { + DIRECT_TEMPLATE_RECIPIENT_EMAIL, + DIRECT_TEMPLATE_RECIPIENT_NAME, +} from '@documenso/lib/constants/template'; + import { prisma } from '..'; import type { Prisma, User } from '../client'; import { DocumentDataType, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '../client'; @@ -13,6 +18,7 @@ type SeedTemplateOptions = { title?: string; userId: number; teamId?: number; + createTemplateOptions?: Partial; }; type CreateTemplateOptions = { @@ -88,3 +94,81 @@ export const seedTemplate = async (options: SeedTemplateOptions) => { }, }); }; + +export const seedDirectTemplate = async (options: SeedTemplateOptions) => { + const { title = 'Untitled', userId, teamId } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + const template = await prisma.template.create({ + data: { + title, + templateDocumentData: { + connect: { + id: documentData.id, + }, + }, + User: { + connect: { + id: userId, + }, + }, + Recipient: { + create: { + email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, + name: DIRECT_TEMPLATE_RECIPIENT_NAME, + token: Math.random().toString().slice(2, 7), + }, + }, + ...(teamId + ? { + team: { + connect: { + id: teamId, + }, + }, + } + : {}), + ...options.createTemplateOptions, + }, + include: { + Recipient: true, + User: true, + }, + }); + + const directTemplateRecpient = template.Recipient.find( + (recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL, + ); + + if (!directTemplateRecpient) { + throw new Error('Need to create a direct template recipient'); + } + + await prisma.templateDirectLink.create({ + data: { + templateId: template.id, + enabled: true, + token: Math.random().toString(), + directTemplateRecipientId: directTemplateRecpient.id, + }, + }); + + return await prisma.template.findFirstOrThrow({ + where: { + id: template.id, + }, + include: { + directLink: true, + Field: true, + Recipient: true, + team: true, + }, + }); +}; diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts index 9f7f80a71..8583473bb 100644 --- a/packages/prisma/seed/users.ts +++ b/packages/prisma/seed/users.ts @@ -4,8 +4,6 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { prisma } from '..'; -export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`; - type SeedUserOptions = { name?: string; email?: string; @@ -15,6 +13,8 @@ type SeedUserOptions = { const nanoid = customAlphabet('1234567890abcdef', 10); +export const seedTestEmail = () => `${nanoid()}@test.documenso.com`; + export const seedUser = async ({ name, email, diff --git a/packages/prisma/types/template.ts b/packages/prisma/types/template.ts index c5dc054a7..093509993 100644 --- a/packages/prisma/types/template.ts +++ b/packages/prisma/types/template.ts @@ -3,6 +3,7 @@ import type { Field, Recipient, Template, + TemplateDirectLink, TemplateMeta, } from '@documenso/prisma/client'; @@ -12,6 +13,7 @@ export type TemplateWithData = Template & { }; export type TemplateWithDetails = Template & { + directLink: TemplateDirectLink | null; templateDocumentData: DocumentData; templateMeta: TemplateMeta | null; Recipient: Recipient[]; diff --git a/packages/trpc/server/singleplayer-router/router.ts b/packages/trpc/server/singleplayer-router/router.ts index 2634ca895..1df83b405 100644 --- a/packages/trpc/server/singleplayer-router/router.ts +++ b/packages/trpc/server/singleplayer-router/router.ts @@ -13,6 +13,7 @@ import { getFile } from '@documenso/lib/universal/upload/get-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { prisma } from '@documenso/prisma'; import { + DocumentSource, DocumentStatus, FieldType, ReadStatus, @@ -95,6 +96,7 @@ export const singleplayerRouter = router({ // Create document. const document = await tx.document.create({ data: { + source: DocumentSource.DOCUMENT, title: documentName, status: DocumentStatus.COMPLETED, documentDataId, diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 112ab6006..f708ee2d7 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -1,24 +1,32 @@ import { TRPCError } from '@trpc/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; -import { AppError } from '@documenso/lib/errors/app-error'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; +import { createDocumentFromDirectTemplate } from '@documenso/lib/server-only/template/create-document-from-direct-template'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createTemplate } from '@documenso/lib/server-only/template/create-template'; +import { createTemplateDirectLink } from '@documenso/lib/server-only/template/create-template-direct-link'; import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; +import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/delete-template-direct-link'; import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template'; import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id'; +import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link'; import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { Document } from '@documenso/prisma/client'; -import { authenticatedProcedure, router } from '../trpc'; +import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc'; import { + ZCreateDocumentFromDirectTemplateMutationSchema, ZCreateDocumentFromTemplateMutationSchema, + ZCreateTemplateDirectLinkMutationSchema, ZCreateTemplateMutationSchema, + ZDeleteTemplateDirectLinkMutationSchema, ZDeleteTemplateMutationSchema, ZDuplicateTemplateMutationSchema, ZGetTemplateWithDetailsByIdQuerySchema, + ZToggleTemplateDirectLinkMutationSchema, ZUpdateTemplateSettingsMutationSchema, } from './schema'; @@ -45,6 +53,36 @@ export const templateRouter = router({ } }), + createDocumentFromDirectTemplate: maybeAuthenticatedProcedure + .input(ZCreateDocumentFromDirectTemplateMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { directRecipientEmail, directTemplateToken, signedFieldValues, templateUpdatedAt } = + input; + + const requestMetadata = extractNextApiRequestMetadata(ctx.req); + + return await createDocumentFromDirectTemplate({ + directRecipientEmail, + directTemplateToken, + signedFieldValues, + templateUpdatedAt, + user: ctx.user + ? { + id: ctx.user.id, + name: ctx.user.name || undefined, + email: ctx.user.email, + } + : undefined, + requestMetadata, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + createDocumentFromTemplate: authenticatedProcedure .input(ZCreateDocumentFromTemplateMutationSchema) .mutation(async ({ input, ctx }) => { @@ -175,4 +213,64 @@ export const templateRouter = router({ }); } }), + + createTemplateDirectLink: authenticatedProcedure + .input(ZCreateTemplateDirectLinkMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId, directRecipientId } = input; + + const userId = ctx.user.id; + + const limits = await getServerLimits({ email: ctx.user.email }); + + if (limits.remaining.directTemplates === 0) { + throw new AppError( + AppErrorCode.LIMIT_EXCEEDED, + 'You have reached your direct templates limit.', + ); + } + + return await createTemplateDirectLink({ userId, templateId, directRecipientId }); + } catch (err) { + console.error(err); + + const error = AppError.parseError(err); + throw AppError.parseErrorToTRPCError(error); + } + }), + + deleteTemplateDirectLink: authenticatedProcedure + .input(ZDeleteTemplateDirectLinkMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId } = input; + + const userId = ctx.user.id; + + return await deleteTemplateDirectLink({ userId, templateId }); + } catch (err) { + console.error(err); + + const error = AppError.parseError(err); + throw AppError.parseErrorToTRPCError(error); + } + }), + + toggleTemplateDirectLink: authenticatedProcedure + .input(ZToggleTemplateDirectLinkMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { templateId, enabled } = input; + + const userId = ctx.user.id; + + return await toggleTemplateDirectLink({ userId, templateId, enabled }); + } catch (err) { + console.error(err); + + const error = AppError.parseError(err); + throw AppError.parseErrorToTRPCError(error); + } + }), }); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 79d609488..36fde8453 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -6,12 +6,21 @@ import { ZDocumentActionAuthTypesSchema, } from '@documenso/lib/types/document-auth'; +import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema'; + export const ZCreateTemplateMutationSchema = z.object({ title: z.string().min(1).trim(), teamId: z.number().optional(), templateDocumentDataId: z.string().min(1), }); +export const ZCreateDocumentFromDirectTemplateMutationSchema = z.object({ + directRecipientEmail: z.string().email(), + directTemplateToken: z.string().min(1), + signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema), + templateUpdatedAt: z.date(), +}); + export const ZCreateDocumentFromTemplateMutationSchema = z.object({ templateId: z.number(), teamId: z.number().optional(), @@ -35,6 +44,20 @@ export const ZDuplicateTemplateMutationSchema = z.object({ teamId: z.number().optional(), }); +export const ZCreateTemplateDirectLinkMutationSchema = z.object({ + templateId: z.number().min(1), + directRecipientId: z.number().min(1).optional(), +}); + +export const ZDeleteTemplateDirectLinkMutationSchema = z.object({ + templateId: z.number().min(1), +}); + +export const ZToggleTemplateDirectLinkMutationSchema = z.object({ + templateId: z.number().min(1), + enabled: z.boolean(), +}); + export const ZDeleteTemplateMutationSchema = z.object({ id: z.number().min(1), }); diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index b2543e363..396d172b7 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -29,6 +29,16 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => { }); }); +export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next }) => { + return await next({ + ctx: { + ...ctx, + user: ctx.user, + session: ctx.session, + }, + }); +}); + export const adminMiddleware = t.middleware(async ({ ctx, next }) => { if (!ctx.session || !ctx.user) { throw new TRPCError({ @@ -49,7 +59,6 @@ export const adminMiddleware = t.middleware(async ({ ctx, next }) => { return await next({ ctx: { ...ctx, - user: ctx.user, session: ctx.session, }, @@ -62,4 +71,6 @@ export const adminMiddleware = t.middleware(async ({ ctx, next }) => { export const router = t.router; export const procedure = t.procedure; export const authenticatedProcedure = t.procedure.use(authenticatedMiddleware); +// While this is functionally the same as `procedure`, it's useful for indicating purpose +export const maybeAuthenticatedProcedure = t.procedure.use(maybeAuthenticatedMiddleware); export const adminProcedure = t.procedure.use(adminMiddleware); diff --git a/packages/ui/components/recipient/recipient-role-select.tsx b/packages/ui/components/recipient/recipient-role-select.tsx index eb1735a34..8b7a2c9ae 100644 --- a/packages/ui/components/recipient/recipient-role-select.tsx +++ b/packages/ui/components/recipient/recipient-role-select.tsx @@ -10,88 +10,94 @@ 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'; -export type RecipientRoleSelectProps = SelectProps; +export type RecipientRoleSelectProps = SelectProps & { + hideCCRecipients?: boolean; +}; -export const RecipientRoleSelect = forwardRef((props, ref) => ( - + + {/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */} + {ROLE_ICONS[props.value as RecipientRole]} + - - -
    -
    - {ROLE_ICONS[RecipientRole.SIGNER]} - Needs to sign + + +
    +
    + {ROLE_ICONS[RecipientRole.SIGNER]} + Needs to sign +
    + + + + + +

    The recipient is required to sign the document for it to be completed.

    +
    +
    - - - - - -

    The recipient is required to sign the document for it to be completed.

    -
    -
    -
    - + - -
    -
    - {ROLE_ICONS[RecipientRole.APPROVER]} - Needs to approve + +
    +
    + {ROLE_ICONS[RecipientRole.APPROVER]} + Needs to approve +
    + + + + + +

    The recipient is required to approve the document for it to be completed.

    +
    +
    - - - - - -

    The recipient is required to approve the document for it to be completed.

    -
    -
    -
    - + - -
    -
    - {ROLE_ICONS[RecipientRole.VIEWER]} - Needs to view + +
    +
    + {ROLE_ICONS[RecipientRole.VIEWER]} + Needs to view +
    + + + + + +

    The recipient is required to view the document for it to be completed.

    +
    +
    - - - - - -

    The recipient is required to view the document for it to be completed.

    -
    -
    -
    - + - -
    -
    - {ROLE_ICONS[RecipientRole.CC]} - Receives copy -
    - - - - - -

    - The recipient is not required to take any action and receives a copy of the document - after it is completed. -

    -
    -
    -
    -
    - - -)); + {!hideCCRecipients && ( + +
    +
    + {ROLE_ICONS[RecipientRole.CC]} + Receives copy +
    + + + + + +

    + The recipient is not required to take any action and receives a copy of the + document after it is completed. +

    +
    +
    +
    +
    + )} + + + ), +); RecipientRoleSelect.displayName = 'RecipientRoleSelect'; diff --git a/packages/ui/primitives/data-table-pagination.tsx b/packages/ui/primitives/data-table-pagination.tsx index 13d63442e..1d419ba87 100644 --- a/packages/ui/primitives/data-table-pagination.tsx +++ b/packages/ui/primitives/data-table-pagination.tsx @@ -34,7 +34,7 @@ export function DataTablePagination({ const visibleRows = table.getFilteredRowModel().rows.length; return ( - + Showing {visibleRows} result{visibleRows > 1 && 's'}. ); diff --git a/packages/ui/primitives/dialog.tsx b/packages/ui/primitives/dialog.tsx index c4491b9a0..66c186773 100644 --- a/packages/ui/primitives/dialog.tsx +++ b/packages/ui/primitives/dialog.tsx @@ -74,7 +74,10 @@ const DialogContent = React.forwardRef< > {children} {!hideClose && ( - + Close diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index 1069290e6..a1bce432d 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -12,7 +12,6 @@ import { match } from 'ts-pattern'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { getFile } from '@documenso/lib/universal/upload/get-file'; import type { DocumentData } from '@documenso/prisma/client'; -import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { cn } from '../lib/utils'; import { PasswordDialog } from './document-password-dialog'; @@ -46,7 +45,6 @@ const PDFLoader = () => ( export type PDFViewerProps = { className?: string; documentData: DocumentData; - document?: DocumentWithData; password?: string | null; onPasswordSubmit?: (password: string) => void | Promise; onDocumentLoad?: (_doc: LoadedPDFDocument) => void; diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx index aa6eaec3c..d45251e01 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -1,15 +1,17 @@ 'use client'; -import React, { useId, useMemo, useState } from 'react'; +import React, { useEffect, useId, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { motion } from 'framer-motion'; -import { Plus, Trash } from 'lucide-react'; +import { Link2Icon, Plus, Trash } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { useFieldArray, useForm } from 'react-hook-form'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { nanoid } from '@documenso/lib/universal/id'; +import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates'; +import type { TemplateDirectLink } from '@documenso/prisma/client'; import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select'; @@ -30,6 +32,7 @@ import { ShowFieldItem } from '../document-flow/show-field-item'; import type { DocumentFlowStep } from '../document-flow/types'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { useStep } from '../stepper'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; @@ -37,6 +40,7 @@ export type AddTemplatePlaceholderRecipientsFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; + templateDirectLink: TemplateDirectLink | null; isEnterprise: boolean; isDocumentPdfLoaded: boolean; onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void; @@ -46,6 +50,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ documentFlow, isEnterprise, recipients, + templateDirectLink, fields, isDocumentPdfLoaded, onSubmit, @@ -61,32 +66,43 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ const { currentStep, totalSteps, previousStep } = useStep(); + const generateDefaultFormSigners = () => { + if (recipients.length === 0) { + return [ + { + formId: initialId, + role: RecipientRole.SIGNER, + actionAuth: undefined, + ...generateRecipientPlaceholder(1), + }, + ]; + } + + return recipients.map((recipient) => ({ + nativeId: recipient.id, + formId: String(recipient.id), + name: recipient.name, + email: recipient.email, + role: recipient.role, + actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, + })); + }; + const form = useForm({ resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema), defaultValues: { - signers: - recipients.length > 0 - ? recipients.map((recipient) => ({ - nativeId: recipient.id, - formId: String(recipient.id), - name: recipient.name, - email: recipient.email, - role: recipient.role, - actionAuth: - ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, - })) - : [ - { - formId: initialId, - name: `Recipient 1`, - email: `recipient.1@documenso.com`, - role: RecipientRole.SIGNER, - actionAuth: undefined, - }, - ], + signers: generateDefaultFormSigners(), }, }); + useEffect(() => { + form.reset({ + signers: generateDefaultFormSigners(), + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recipients]); + // Always show advanced settings if any recipient has auth options. const alwaysShowAdvancedSettings = useMemo(() => { const recipientHasAuthOptions = recipients.find((recipient) => { @@ -130,11 +146,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ const onAddPlaceholderRecipient = () => { appendSigner({ formId: nanoid(12), - // Update TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX if this is ever changed. - name: `Recipient ${placeholderRecipientCount}`, - // Update TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX if this is ever changed. - email: `recipient.${placeholderRecipientCount}@documenso.com`, role: RecipientRole.SIGNER, + ...generateRecipientPlaceholder(placeholderRecipientCount), }); setPlaceholderRecipientCount((count) => count + 1); @@ -144,6 +157,15 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ removeSigner(index); }; + const isSignerDirectRecipient = ( + signer: TAddTemplatePlacholderRecipientsFormSchema['signers'][number], + ): boolean => { + return ( + templateDirectLink !== null && + signer.nativeId === templateDirectLink?.directTemplateRecipientId + ); + }; + return ( <> @@ -183,7 +205,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ type="email" placeholder="Email" {...field} - disabled={isSubmitting || signers[index].email === user?.email} + disabled={ + field.disabled || + isSubmitting || + signers[index].email === user?.email || + isSignerDirectRecipient(signer) + } /> @@ -208,7 +235,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ @@ -246,6 +278,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ {...field} onValueChange={field.onChange} disabled={isSubmitting} + hideCCRecipients={isSignerDirectRecipient(signer)} /> @@ -254,14 +287,32 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ )} /> - + {isSignerDirectRecipient(signer) ? ( + + + + + +

    + Direct link receiver +

    +

    + This field cannot be modified or deleted. When you share this template's + direct link or add it to your public profile, anyone who accesses it can + input their name and email, and fill in the fields assigned to them. +

    +
    +
    + ) : ( + + )} ))}
    From 0c2306b7451f2672def02e9ad13f5dbd25fac72d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 5 Jun 2024 14:40:45 +1000 Subject: [PATCH 56/56] fix: add correct role names for direct templates (#1179) ## Description Update the direct template signing process and emails to correctly reflect the role of the recipient who actioned the direct template. ## Summary by CodeRabbit - **New Features** - Dynamic updating of title and description based on recipient role in the document signing process. - Enhanced email templates to include recipient roles, providing more context in email notifications. - **Improvements** - More descriptive actions in email templates based on recipient roles, improving clarity for recipients. --- .../src/app/(recipient)/d/[token]/direct-template.tsx | 7 +++++-- .../document-created-from-direct-template.tsx | 10 ++++++++-- .../template/create-document-from-direct-template.ts | 1 + 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx index 8bb3756f4..2ef832dfc 100644 --- a/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx +++ b/apps/web/src/app/(recipient)/d/[token]/direct-template.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { Field } from '@documenso/prisma/client'; import { type Recipient } from '@documenso/prisma/client'; import type { TemplateWithDetails } from '@documenso/prisma/types/template'; @@ -47,6 +48,8 @@ export const DirectTemplatePageView = ({ const [step, setStep] = useState('configure'); const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); + const recipientRoleDescription = RECIPIENT_ROLES_DESCRIPTION[directTemplateRecipient.role]; + const directTemplateFlow: Record = { configure: { title: 'General', @@ -54,8 +57,8 @@ export const DirectTemplatePageView = ({ stepIndex: 1, }, sign: { - title: 'Sign document', - description: 'Sign the document to complete the process.', + title: `${recipientRoleDescription.actionVerb} document`, + description: `${recipientRoleDescription.actionVerb} the document to complete the process.`, stepIndex: 2, }, }; diff --git a/packages/email/templates/document-created-from-direct-template.tsx b/packages/email/templates/document-created-from-direct-template.tsx index e1512d041..0c66f25af 100644 --- a/packages/email/templates/document-created-from-direct-template.tsx +++ b/packages/email/templates/document-created-from-direct-template.tsx @@ -1,3 +1,4 @@ +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import config from '@documenso/tailwind-config'; import { @@ -14,9 +15,11 @@ import { } from '../components'; import TemplateDocumentImage from '../template-components/template-document-image'; import { TemplateFooter } from '../template-components/template-footer'; +import { RecipientRole } from '.prisma/client'; export type DocumentCompletedEmailTemplateProps = { recipientName?: string; + recipientRole?: RecipientRole; documentLink?: string; documentName?: string; assetBaseUrl?: string; @@ -24,11 +27,14 @@ export type DocumentCompletedEmailTemplateProps = { export const DocumentCreatedFromDirectTemplateEmailTemplate = ({ recipientName = 'John Doe', + recipientRole = RecipientRole.SIGNER, documentLink = 'http://localhost:3000', documentName = 'Open Source Pledge.pdf', assetBaseUrl = 'http://localhost:3002', }: DocumentCompletedEmailTemplateProps) => { - const previewText = `Completed Document`; + const action = RECIPIENT_ROLES_DESCRIPTION[recipientRole].actioned.toLowerCase(); + + const previewText = `Document created from direct template`; const getAssetUrl = (path: string) => { return new URL(path, assetBaseUrl).toString(); @@ -61,7 +67,7 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
    - {recipientName} signed a document by using one of your direct links + {recipientName} {action} a document by using one of your direct links
    diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index eeb639bb8..229851729 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -480,6 +480,7 @@ export const createDocumentFromDirectTemplate = async ({ // Send email to template owner. const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, { recipientName: directRecipientEmail, + recipientRole: directTemplateRecipient.role, documentLink: `${formatDocumentsPath(document.team?.url)}/${document.id}`, documentName: document.title, assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',