diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..b6fba867d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# Agent Guidelines for Documenso + +## Build/Test/Lint Commands + +- `npm run build` - Build all packages +- `npm run lint` - Lint all packages +- `npm run lint:fix` - Auto-fix linting issues +- `npm run test:e2e` - Run E2E tests with Playwright +- `npm run test:dev -w @documenso/app-tests` - Run single E2E test in dev mode +- `npm run test-ui:dev -w @documenso/app-tests` - Run E2E tests with UI +- `npm run format` - Format code with Prettier +- `npm run dev` - Start development server for Remix app + +## Code Style Guidelines + +- Use TypeScript for all code; prefer `type` over `interface` +- Use functional components with `const Component = () => {}` +- Never use classes; prefer functional/declarative patterns +- Use descriptive variable names with auxiliary verbs (isLoading, hasError) +- Directory names: lowercase with dashes (auth-wizard) +- Use named exports for components +- Never use 'use client' directive +- Never use 1-line if statements +- Structure files: exported component, subcomponents, helpers, static content, types + +## Error Handling & Validation + +- Use custom AppError class when throwing errors +- When catching errors on the frontend use `const error = AppError.parse(error)` to get the error code +- Use early returns and guard clauses +- Use Zod for form validation and react-hook-form for forms +- Use error boundaries for unexpected errors + +## UI & Styling + +- Use Shadcn UI, Radix, and Tailwind CSS with mobile-first approach +- Use `
` `` elements with fieldset having `:disabled` attribute when loading +- Use Lucide icons with longhand names (HomeIcon vs Home) + +## TRPC Routes + +- Each route in own file: `routers/teams/create-team.ts` +- Associated types file: `routers/teams/create-team.types.ts` +- Request/response schemas: `Z[RouteName]RequestSchema`, `Z[RouteName]ResponseSchema` +- Only use GET and POST methods in OpenAPI meta +- Deconstruct input argument on its own line +- Prefer route names such as get/getMany/find/create/update/delete +- "create" routes request schema should have the ID and data in the top level +- "update" routes request schema should have the ID in the top level and the data in a nested "data" object + +## Translations & Remix + +- Use `string` for JSX translations from `@lingui/react/macro` +- Use `t\`string\`` macro for TypeScript translations +- Use `(params: Route.Params)` and `(loaderData: Route.LoaderData)` for routes +- Directly return data from loaders, don't use `json()` +- Use `superLoaderJson` when sending complex data through loaders such as dates or prisma decimals diff --git a/README.md b/README.md index f44b88c2a..185d9839e 100644 --- a/README.md +++ b/README.md @@ -214,8 +214,6 @@ For detailed instructions on how to configure and run the Docker container, plea We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates! -> Please note that the below deployment methods are for v0.9, we will update these to v1.0 once it has been released. - ### Fetch, configure, and build First, clone the code from Github: @@ -258,7 +256,7 @@ npm run start This will start the server on `localhost:3000`. For now, any reverse proxy can then do the frontend and SSL termination. -> If you want to run with another port than 3000, you can start the application with `next -p ` from the `apps/web` folder. +> If you want to run with another port than 3000, you can start the application with `next -p ` from the `apps/remix` folder. ### Run as a service @@ -308,7 +306,7 @@ The Web UI can be found at http://localhost:9000, while the SMTP port will be on ### Support IPv6 -If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Next.js start command +If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command For local docker run diff --git a/SIGNING.md b/SIGNING.md index d8f664cee..3eb94fbfb 100644 --- a/SIGNING.md +++ b/SIGNING.md @@ -18,7 +18,7 @@ For the digital signature of your documents you need a signing certificate in .p 4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**) -5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created) +5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created) ## Docker diff --git a/apps/documentation/pages/developers/contributing/contributing-translations.mdx b/apps/documentation/pages/developers/contributing/contributing-translations.mdx index e313a4dc1..b8ffbb362 100644 --- a/apps/documentation/pages/developers/contributing/contributing-translations.mdx +++ b/apps/documentation/pages/developers/contributing/contributing-translations.mdx @@ -25,7 +25,7 @@ The translation files are organized into folders represented by their respective Each PO file contains translations which look like this: ```po -#: apps/web/src/app/(signing)/sign/[token]/no-longer-available.tsx:61 +#: apps/remix/app/(signing)/sign/[token]/no-longer-available.tsx:61 msgid "Want to send slick signing links like this one? <0>Check out Documenso." msgstr "Möchten Sie auffällige Signatur-Links wie diesen senden? <0>Überprüfen Sie Documenso." ``` diff --git a/apps/documentation/pages/developers/self-hosting/how-to.mdx b/apps/documentation/pages/developers/self-hosting/how-to.mdx index 4025ce6d0..08797e801 100644 --- a/apps/documentation/pages/developers/self-hosting/how-to.mdx +++ b/apps/documentation/pages/developers/self-hosting/how-to.mdx @@ -54,7 +54,7 @@ Install the project dependencies as follows: ```bash npm i -npm run build:web +npm run build npm run prisma:migrate-deploy ``` @@ -69,7 +69,7 @@ npm run start This will start the server on `localhost:3000`. Any reverse proxy can handle the front end and SSL termination. - If you want to run with another port than `3000`, you can start the application with `next -p ` from the `apps/web` folder. + If you want to run with another port than `3000`, you can start the application with `next -p ` from the `apps/remix` folder. @@ -249,7 +249,7 @@ After=network.target Environment=PATH=/path/to/your/node/binaries Type=simple User=www-data -WorkingDirectory=/var/www/documenso/apps/web +WorkingDirectory=/var/www/documenso/apps/remix ExecStart=/usr/bin/next start -p 3500 TimeoutSec=15 Restart=always diff --git a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx index 26692fedb..9f82d8551 100644 --- a/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx @@ -34,7 +34,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo const [reason, setReason] = useState(''); const { mutateAsync: deleteDocument, isPending: isDeletingDocument } = - trpc.admin.deleteDocument.useMutation(); + trpc.admin.document.delete.useMutation(); const handleDeleteDocument = async () => { try { diff --git a/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx index 58ffa9c85..157cc5284 100644 --- a/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx @@ -3,12 +3,12 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { User } from '@prisma/client'; import { useNavigate } from 'react-router'; import { match } from 'ts-pattern'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; +import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminUserDeleteDialogProps = { className?: string; - user: User; + user: TGetUserResponse; }; export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialogProps) => { @@ -35,7 +35,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog const [email, setEmail] = useState(''); const { mutateAsync: deleteUser, isPending: isDeletingUser } = - trpc.admin.deleteUser.useMutation(); + trpc.admin.user.delete.useMutation(); const onDeleteAccount = async () => { try { diff --git a/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx index ee42931a9..347532a19 100644 --- a/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx @@ -3,11 +3,11 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { User } from '@prisma/client'; import { match } from 'ts-pattern'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; +import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminUserDisableDialogProps = { className?: string; - userToDisable: User; + userToDisable: TGetUserResponse; }; export const AdminUserDisableDialog = ({ @@ -37,7 +37,7 @@ export const AdminUserDisableDialog = ({ const [email, setEmail] = useState(''); const { mutateAsync: disableUser, isPending: isDisablingUser } = - trpc.admin.disableUser.useMutation(); + trpc.admin.user.disable.useMutation(); const onDisableAccount = async () => { try { diff --git a/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx index 1718c9e97..64f9aa72d 100644 --- a/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -3,11 +3,11 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { User } from '@prisma/client'; import { match } from 'ts-pattern'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; +import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -24,7 +24,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminUserEnableDialogProps = { className?: string; - userToEnable: User; + userToEnable: TGetUserResponse; }; export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnableDialogProps) => { @@ -34,7 +34,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab const [email, setEmail] = useState(''); const { mutateAsync: enableUser, isPending: isEnablingUser } = - trpc.admin.enableUser.useMutation(); + trpc.admin.user.enable.useMutation(); const onEnableAccount = async () => { try { diff --git a/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx index f95657d9f..59372ecc9 100644 --- a/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-reset-two-factor-dialog.tsx @@ -3,12 +3,12 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { User } from '@prisma/client'; import { useRevalidator } from 'react-router'; import { match } from 'ts-pattern'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; +import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -25,7 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type AdminUserResetTwoFactorDialogProps = { className?: string; - user: User; + user: TGetUserResponse; }; export const AdminUserResetTwoFactorDialog = ({ diff --git a/apps/remix/app/components/dialogs/document-delete-dialog.tsx b/apps/remix/app/components/dialogs/document-delete-dialog.tsx index 746ef1570..a802387ef 100644 --- a/apps/remix/app/components/dialogs/document-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-delete-dialog.tsx @@ -49,7 +49,7 @@ export const DocumentDeleteDialog = ({ const [inputValue, setInputValue] = useState(''); const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT); - const { mutateAsync: deleteDocument, isPending } = trpcReact.document.deleteDocument.useMutation({ + const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({ onSuccess: async () => { void refreshLimits(); diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index bb87f99dc..57146ed9f 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -36,11 +36,12 @@ export const DocumentDuplicateDialog = ({ const team = useCurrentTeam(); - const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery( + const { data: document, isLoading } = trpcReact.document.get.useQuery( { documentId: id, }, { + queryHash: `document-duplicate-dialog-${id}`, enabled: open === true, }, ); @@ -55,7 +56,7 @@ export const DocumentDuplicateDialog = ({ const documentsPath = formatDocumentsPath(team.url); const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } = - trpcReact.document.duplicateDocument.useMutation({ + trpcReact.document.duplicate.useMutation({ onSuccess: async ({ documentId }) => { toast({ title: _(msg`Document Duplicated`), diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index b3dc69503..e1a97ecc1 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -71,7 +71,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia document.status !== 'PENDING' || !recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED); - const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation(); + const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation(); const form = useForm({ resolver: zodResolver(ZResendDocumentFormSchema), diff --git a/apps/remix/app/components/dialogs/passkey-create-dialog.tsx b/apps/remix/app/components/dialogs/passkey-create-dialog.tsx index 6a21895d3..8bfcbe3e5 100644 --- a/apps/remix/app/components/dialogs/passkey-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/passkey-create-dialog.tsx @@ -65,9 +65,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre }); const { mutateAsync: createPasskeyRegistrationOptions, isPending } = - trpc.auth.createPasskeyRegistrationOptions.useMutation(); + trpc.auth.passkey.createRegistrationOptions.useMutation(); - const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation(); + const { mutateAsync: createPasskey } = trpc.auth.passkey.create.useMutation(); const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => { setFormError(null); diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index aa557132b..bd20c2377 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -56,7 +56,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe type TDeleteTokenByIdMutationSchema = z.infer; - const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({ + const { mutateAsync: deleteTokenMutation } = trpc.apiToken.delete.useMutation({ onSuccess() { onDelete?.(); }, diff --git a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx index 60b732a76..21faee750 100644 --- a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx +++ b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -172,6 +172,8 @@ export const ConfigureFieldsView = ({ name: 'fields', }); + const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber)); + const onFieldCopy = useCallback( (event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => { const { duplicate = false, duplicateAll = false } = options ?? {}; @@ -540,7 +542,9 @@ export const ConfigureFieldsView = ({
- + {localFields.map((field, index) => { const recipientIndex = recipients.findIndex( (r) => r.id === field.recipientId, diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 09f6a91d2..db429f7e4 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -57,7 +57,7 @@ export const EmbedDirectTemplateClientPage = ({ token, updatedAt, documentData, - recipient, + recipient: _recipient, fields, metadata, hidePoweredBy = false, @@ -91,8 +91,12 @@ export const EmbedDirectTemplateClientPage = ({ localFields.filter((field) => field.inserted), ]; + const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); + const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); + const signatureValid = !hasSignatureField || (signature && signature.trim() !== ''); + const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } = trpc.template.createDocumentFromDirectTemplate.useMutation(); @@ -343,19 +347,34 @@ export const EmbedDirectTemplateClientPage = ({ Sign document - + {isExpanded ? ( + + ) : pendingFields.length > 0 ? ( + + ) : ( + + )}
@@ -442,7 +461,9 @@ export const EmbedDirectTemplateClientPage = ({ - + {showPendingFieldTooltip && pendingFields.length > 0 && ( Click to insert field diff --git a/apps/remix/app/components/embed/embed-document-fields.tsx b/apps/remix/app/components/embed/embed-document-fields.tsx index 561fdf4cb..ea14b3f1f 100644 --- a/apps/remix/app/components/embed/embed-document-fields.tsx +++ b/apps/remix/app/components/embed/embed-document-fields.tsx @@ -50,8 +50,10 @@ export const EmbedDocumentFields = ({ onSignField, onUnsignField, }: EmbedDocumentFieldsProps) => { + const highestPageNumber = Math.max(...fields.map((field) => field.page)); + return ( - + {fields.map((field) => match(field.type) .with(FieldType.SIGNATURE, () => ( diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx index ef2eedc1c..2f24dceeb 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -89,7 +89,7 @@ export const EmbedSignDocumentClientPage = ({ const [isExpanded, setIsExpanded] = useState(false); const [isNameLocked, setIsNameLocked] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); - const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] = + const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] = useState(false); const [allowDocumentRejection, setAllowDocumentRejection] = useState(false); @@ -106,6 +106,8 @@ export const EmbedSignDocumentClientPage = ({ fields.filter((field) => field.inserted), ]; + const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); + const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } = trpc.recipient.completeDocumentWithToken.useMutation(); @@ -116,6 +118,8 @@ export const EmbedSignDocumentClientPage = ({ const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); + const signatureValid = !hasSignatureField || (signature && signature.trim() !== ''); + const assistantSignersId = useId(); const onNextFieldClick = () => { @@ -305,19 +309,36 @@ export const EmbedSignDocumentClientPage = ({ )} - + {isExpanded ? ( + + ) : pendingFields.length > 0 ? ( + + ) : ( + + )} @@ -465,7 +486,9 @@ export const EmbedSignDocumentClientPage = ({ - + {showPendingFieldTooltip && pendingFields.length > 0 && ( Click to insert field diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index 37abdcce3..5867a1a2a 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -92,6 +92,8 @@ export const MultiSignDocumentSigningView = ({ [], ]; + const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); + const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? []; const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => { @@ -357,7 +359,9 @@ export const MultiSignDocumentSigningView = ({ {hasDocumentLoaded && ( - + {showPendingFieldTooltip && pendingFields.length > 0 && ( ({ values: { diff --git a/apps/remix/app/components/forms/token.tsx b/apps/remix/app/components/forms/token.tsx index 13b7da0b1..1b8e4f3e3 100644 --- a/apps/remix/app/components/forms/token.tsx +++ b/apps/remix/app/components/forms/token.tsx @@ -13,7 +13,7 @@ import type { z } from 'zod'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; -import { ZCreateTokenMutationSchema } from '@documenso/trpc/server/api-token-router/schema'; +import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -47,7 +47,7 @@ export const EXPIRATION_DATES = { ONE_YEAR: msg`12 months`, } as const; -const ZCreateTokenFormSchema = ZCreateTokenMutationSchema.pick({ +const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({ tokenName: true, expirationDate: true, }); @@ -75,7 +75,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => { const [newlyCreatedToken, setNewlyCreatedToken] = useState(); const [noExpirationDate, setNoExpirationDate] = useState(false); - const { mutateAsync: createTokenMutation } = trpc.apiToken.createToken.useMutation({ + const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({ onSuccess(data) { setNewlyCreatedToken(data); }, diff --git a/apps/remix/app/components/general/admin-monthly-active-user-charts.tsx b/apps/remix/app/components/general/admin-monthly-active-user-charts.tsx index 048d9599c..c6f137c3d 100644 --- a/apps/remix/app/components/general/admin-monthly-active-user-charts.tsx +++ b/apps/remix/app/components/general/admin-monthly-active-user-charts.tsx @@ -1,5 +1,3 @@ -'use client'; - import { DateTime } from 'luxon'; import type { TooltipProps } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; diff --git a/apps/remix/app/components/general/app-command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx index 9e9724d2e..2305bc6af 100644 --- a/apps/remix/app/components/general/app-command-menu.tsx +++ b/apps/remix/app/components/general/app-command-menu.tsx @@ -64,7 +64,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) { const [pages, setPages] = useState([]); const { data: searchDocumentsData, isPending: isSearchingDocuments } = - trpcReact.document.searchDocuments.useQuery( + trpcReact.document.search.useQuery( { query: search, }, diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index da38f51c4..8f78ae754 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -79,6 +79,8 @@ export const DirectTemplateSigningForm = ({ const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const highestPageNumber = Math.max(...localFields.map((field) => field.page)); + const fieldsRequiringValidation = useMemo(() => { return localFields.filter((field) => isFieldUnsignedAndRequired(field)); }, [localFields]); @@ -221,7 +223,9 @@ export const DirectTemplateSigningForm = ({ - + {validateUninsertedFields && uninsertedFields[0] && ( Click to insert field diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx index 22e641713..930738c74 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx @@ -77,7 +77,7 @@ export const DocumentSigningAuthPasskey = ({ }); const { mutateAsync: createPasskeyAuthenticationOptions } = - trpc.auth.createPasskeyAuthenticationOptions.useMutation(); + trpc.auth.passkey.createAuthenticationOptions.useMutation(); const [formErrorCode, setFormErrorCode] = useState(null); diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx index 42e5ffd5b..c3b1be53e 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-provider.tsx @@ -93,7 +93,7 @@ export const DocumentSigningAuthProvider = ({ [documentAuthOptions, recipient], ); - const passkeyQuery = trpc.auth.findPasskeys.useQuery( + const passkeyQuery = trpc.auth.passkey.find.useQuery( { perPage: MAXIMUM_PASSKEYS, }, diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index b3356bee8..b2b58aa3b 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -7,14 +7,11 @@ import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/cl import { Controller, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; -import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; -import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; -import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { sortFieldsByPosition } from '@documenso/lib/utils/fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; -import { trpc } from '@documenso/trpc/react'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { Button } from '@documenso/ui/primitives/button'; import { Input } from '@documenso/ui/primitives/input'; @@ -34,29 +31,33 @@ export type DocumentSigningFormProps = { document: DocumentAndSender; recipient: Recipient; fields: Field[]; - redirectUrl?: string | null; isRecipientsTurn: boolean; allRecipients?: RecipientWithFields[]; setSelectedSignerId?: (id: number | null) => void; + completeDocument: ( + authOptions?: TRecipientActionAuth, + nextSigner?: { email: string; name: string }, + ) => Promise; + isSubmitting: boolean; + fieldsValidated: () => void; + nextRecipient?: RecipientWithFields; }; export const DocumentSigningForm = ({ document, recipient, fields, - redirectUrl, isRecipientsTurn, allRecipients = [], setSelectedSignerId, + completeDocument, + isSubmitting, + fieldsValidated, + nextRecipient, }: DocumentSigningFormProps) => { - const { sessionData } = useOptionalSession(); - const user = sessionData?.user; - const { _ } = useLingui(); const { toast } = useToast(); - const navigate = useNavigate(); - const analytics = useAnalytics(); const assistantSignersId = useId(); @@ -66,21 +67,12 @@ export const DocumentSigningForm = ({ const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false); - const { - mutateAsync: completeDocumentWithToken, - isPending, - isSuccess, - } = trpc.recipient.completeDocumentWithToken.useMutation(); - const assistantForm = useForm<{ selectedSignerId: number | undefined }>({ defaultValues: { selectedSignerId: undefined, }, }); - // Keep the loading state going if successful since the redirect may take some time. - const isSubmitting = isPending || isSuccess; - const fieldsRequiringValidation = useMemo( () => fields.filter(isFieldUnsignedAndRequired), [fields], @@ -96,9 +88,9 @@ export const DocumentSigningForm = ({ return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id); }, [fieldsRequiringValidation, recipient]); - const fieldsValidated = () => { + const localFieldsValidated = () => { setValidateUninsertedFields(true); - validateFieldsInserted(fieldsRequiringValidation); + fieldsValidated(); }; const onAssistantFormSubmit = () => { @@ -126,55 +118,6 @@ export const DocumentSigningForm = ({ } }; - const completeDocument = async ( - authOptions?: TRecipientActionAuth, - nextSigner?: { email: string; name: string }, - ) => { - const payload = { - token: recipient.token, - documentId: document.id, - authOptions, - ...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}), - }; - - await completeDocumentWithToken(payload); - - analytics.capture('App: Recipient has completed signing', { - signerId: recipient.id, - documentId: document.id, - timestamp: new Date().toISOString(), - }); - - if (redirectUrl) { - window.location.href = redirectUrl; - } else { - await navigate(`/sign/${recipient.token}/complete`); - } - }; - - const nextRecipient = useMemo(() => { - if ( - !document.documentMeta?.signingOrder || - document.documentMeta.signingOrder !== 'SEQUENTIAL' - ) { - return undefined; - } - - const sortedRecipients = allRecipients.sort((a, b) => { - // Sort by signingOrder first (nulls last), then by id - if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id; - if (a.signingOrder === null) return 1; - if (b.signingOrder === null) return -1; - if (a.signingOrder === b.signingOrder) return a.id - b.id; - return a.signingOrder - b.signingOrder; - }); - - const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id); - return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 - ? sortedRecipients[currentIndex + 1] - : undefined; - }, [document.documentMeta?.signingOrder, allRecipients, recipient.id]); - return (
{validateUninsertedFields && uninsertedFields[0] && ( @@ -205,7 +148,7 @@ export const DocumentSigningForm = ({ isSubmitting={isSubmitting} documentTitle={document.title} fields={fields} - fieldsValidated={fieldsValidated} + fieldsValidated={localFieldsValidated} onSignatureComplete={async (nextSigner) => { await completeDocument(undefined, nextSigner); }} @@ -364,7 +307,7 @@ export const DocumentSigningForm = ({ isSubmitting={isSubmitting || isAssistantSubmitting} documentTitle={document.title} fields={fields} - fieldsValidated={fieldsValidated} + fieldsValidated={localFieldsValidated} disabled={!isRecipientsTurn} onSignatureComplete={async (nextSigner) => { await completeDocument(undefined, nextSigner); diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx index a1bf3d24e..626a5195f 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx @@ -1,15 +1,18 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Trans } from '@lingui/react/macro'; import type { Field } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; -import { match } from 'ts-pattern'; +import { useNavigate } from 'react-router'; +import { P, match } from 'ts-pattern'; +import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; 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 type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZCheckboxFieldMeta, ZDropdownFieldMeta, @@ -18,8 +21,11 @@ import { ZTextFieldMeta, } from '@documenso/lib/types/field-meta'; import type { CompletedField } from '@documenso/lib/types/fields'; +import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; +import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; +import { trpc } from '@documenso/trpc/react'; import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -40,6 +46,7 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; +import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider'; export type DocumentSigningPageViewProps = { @@ -63,9 +70,56 @@ export const DocumentSigningPageView = ({ }: DocumentSigningPageViewProps) => { const { documentData, documentMeta } = document; + const navigate = useNavigate(); + const analytics = useAnalytics(); + const [selectedSignerId, setSelectedSignerId] = useState(allRecipients?.[0]?.id); const [isExpanded, setIsExpanded] = useState(false); + const { + mutateAsync: completeDocumentWithToken, + isPending, + isSuccess, + } = trpc.recipient.completeDocumentWithToken.useMutation(); + + // Keep the loading state going if successful since the redirect may take some time. + const isSubmitting = isPending || isSuccess; + + const fieldsRequiringValidation = useMemo( + () => fields.filter(isFieldUnsignedAndRequired), + [fields], + ); + + const fieldsValidated = () => { + validateFieldsInserted(fieldsRequiringValidation); + }; + + const completeDocument = async ( + authOptions?: TRecipientActionAuth, + nextSigner?: { email: string; name: string }, + ) => { + const payload = { + token: recipient.token, + documentId: document.id, + authOptions, + ...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}), + }; + + await completeDocumentWithToken(payload); + + analytics.capture('App: Recipient has completed signing', { + signerId: recipient.id, + documentId: document.id, + timestamp: new Date().toISOString(), + }); + + if (documentMeta?.redirectUrl) { + window.location.href = documentMeta.redirectUrl; + } else { + await navigate(`/sign/${recipient.token}/complete`); + } + }; + let senderName = document.user.name ?? ''; let senderEmail = `(${document.user.email})`; @@ -78,6 +132,31 @@ export const DocumentSigningPageView = ({ const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null; + const nextRecipient = useMemo(() => { + if (!documentMeta?.signingOrder || documentMeta.signingOrder !== 'SEQUENTIAL') { + return undefined; + } + + const sortedRecipients = allRecipients.sort((a, b) => { + // Sort by signingOrder first (nulls last), then by id + if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id; + if (a.signingOrder === null) return 1; + if (b.signingOrder === null) return -1; + if (a.signingOrder === b.signingOrder) return a.id - b.id; + return a.signingOrder - b.signingOrder; + }); + + const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id); + return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1 + ? sortedRecipients[currentIndex + 1] + : undefined; + }, [document.documentMeta?.signingOrder, allRecipients, recipient.id]); + + const highestPageNumber = Math.max(...fields.map((field) => field.page)); + + const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted); + const hasPendingFields = pendingFields.length > 0; + return (
@@ -163,19 +242,55 @@ export const DocumentSigningPageView = ({ .otherwise(() => null)} - + )) + .otherwise(() => ( + + > + + + ))}
@@ -204,10 +319,13 @@ export const DocumentSigningPageView = ({ document={document} recipient={recipient} fields={fields} - redirectUrl={documentMeta?.redirectUrl} isRecipientsTurn={isRecipientsTurn} allRecipients={allRecipients} setSelectedSignerId={setSelectedSignerId} + completeDocument={completeDocument} + isSubmitting={isSubmitting} + fieldsValidated={fieldsValidated} + nextRecipient={nextRecipient} />
@@ -224,7 +342,9 @@ export const DocumentSigningPageView = ({ )} - + {fields .filter( (field) => diff --git a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx index fb531eb37..77e90eff8 100644 --- a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx +++ b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx @@ -21,7 +21,7 @@ export const DocumentAuditLogDownloadButton = ({ const { _ } = useLingui(); const { mutateAsync: downloadAuditLogs, isPending } = - trpc.document.downloadAuditLogs.useMutation(); + trpc.document.auditLog.download.useMutation(); const onDownloadAuditLogsClick = async () => { try { diff --git a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx index 16a52194e..e5fe18636 100644 --- a/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx @@ -49,7 +49,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon const { quota, remaining, refreshLimits } = useLimits(); - const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation(); + const { mutateAsync: createDocument } = trpc.document.create.useMutation(); const isUploadDisabled = remaining.documents === 0 || !user.emailVerified; diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index e03744164..e8ffa5fe5 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -59,23 +59,22 @@ export const DocumentEditForm = ({ const utils = trpc.useUtils(); - const { data: document, refetch: refetchDocument } = - trpc.document.getDocumentWithDetailsById.useQuery( - { - documentId: initialDocument.id, - }, - { - initialData: initialDocument, - ...SKIP_QUERY_BATCH_META, - }, - ); + const { data: document, refetch: refetchDocument } = trpc.document.get.useQuery( + { + documentId: initialDocument.id, + }, + { + initialData: initialDocument, + ...SKIP_QUERY_BATCH_META, + }, + ); const { recipients, fields } = document; - const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({ + const { mutateAsync: updateDocument } = trpc.document.update.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newData) => { - utils.document.getDocumentWithDetailsById.setData( + utils.document.get.setData( { documentId: initialDocument.id, }, @@ -84,23 +83,10 @@ export const DocumentEditForm = ({ }, }); - const { mutateAsync: setSigningOrderForDocument } = - trpc.document.setSigningOrderForDocument.useMutation({ - ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, - onSuccess: (newData) => { - utils.document.getDocumentWithDetailsById.setData( - { - documentId: initialDocument.id, - }, - (oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }), - ); - }, - }); - const { mutateAsync: addFields } = trpc.field.addFields.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: ({ fields: newFields }) => { - utils.document.getDocumentWithDetailsById.setData( + utils.document.get.setData( { documentId: initialDocument.id, }, @@ -112,7 +98,7 @@ export const DocumentEditForm = ({ const { mutateAsync: setRecipients } = trpc.recipient.setDocumentRecipients.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: ({ recipients: newRecipients }) => { - utils.document.getDocumentWithDetailsById.setData( + utils.document.get.setData( { documentId: initialDocument.id, }, @@ -121,10 +107,10 @@ export const DocumentEditForm = ({ }, }); - const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation({ + const { mutateAsync: sendDocument } = trpc.document.distribute.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newData) => { - utils.document.getDocumentWithDetailsById.setData( + utils.document.get.setData( { documentId: initialDocument.id, }, @@ -173,34 +159,37 @@ export const DocumentEditForm = ({ return initialStep; }); + const saveSettingsData = async (data: TAddSettingsFormSchema) => { + const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta; + + const parsedGlobalAccessAuth = z + .array(ZDocumentAccessAuthTypesSchema) + .safeParse(data.globalAccessAuth); + + return updateDocument({ + documentId: document.id, + data: { + title: data.title, + externalId: data.externalId || null, + visibility: data.visibility, + globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [], + globalActionAuth: data.globalActionAuth ?? [], + }, + meta: { + timezone, + dateFormat, + redirectUrl, + language: isValidLanguageCode(language) ? language : undefined, + typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), + uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), + drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), + }, + }); + }; + const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { try { - const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta; - - const parsedGlobalAccessAuth = z - .array(ZDocumentAccessAuthTypesSchema) - .safeParse(data.globalAccessAuth); - - await updateDocument({ - documentId: document.id, - data: { - title: data.title, - externalId: data.externalId || null, - visibility: data.visibility, - globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [], - globalActionAuth: data.globalActionAuth ?? [], - }, - meta: { - timezone, - dateFormat, - redirectUrl, - language: isValidLanguageCode(language) ? language : undefined, - typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), - uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), - drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), - }, - }); - + await saveSettingsData(data); setStep('signers'); } catch (err) { console.error(err); @@ -213,30 +202,58 @@ export const DocumentEditForm = ({ } }; + const onAddSettingsFormAutoSave = async (data: TAddSettingsFormSchema) => { + try { + await saveSettingsData(data); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while auto-saving the document settings.`), + variant: 'destructive', + }); + } + }; + + const saveSignersData = async (data: TAddSignersFormSchema) => { + return Promise.all([ + updateDocument({ + documentId: document.id, + meta: { + allowDictateNextSigner: data.allowDictateNextSigner, + signingOrder: data.signingOrder, + }, + }), + + setRecipients({ + documentId: document.id, + recipients: data.signers.map((signer) => ({ + ...signer, + // Explicitly set to null to indicate we want to remove auth if required. + actionAuth: signer.actionAuth ?? [], + })), + }), + ]); + }; + + const onAddSignersFormAutoSave = async (data: TAddSignersFormSchema) => { + try { + await saveSignersData(data); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while adding signers.`), + variant: 'destructive', + }); + } + }; + const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { try { - await Promise.all([ - setSigningOrderForDocument({ - documentId: document.id, - signingOrder: data.signingOrder, - }), - - updateDocument({ - documentId: document.id, - meta: { - allowDictateNextSigner: data.allowDictateNextSigner, - }, - }), - - setRecipients({ - documentId: document.id, - recipients: data.signers.map((signer) => ({ - ...signer, - // Explicitly set to null to indicate we want to remove auth if required. - actionAuth: signer.actionAuth ?? [], - })), - }), - ]); + await saveSignersData(data); setStep('fields'); } catch (err) { @@ -250,12 +267,16 @@ export const DocumentEditForm = ({ } }; + const saveFieldsData = async (data: TAddFieldsFormSchema) => { + return addFields({ + documentId: document.id, + fields: data.fields, + }); + }; + const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => { try { - await addFields({ - documentId: document.id, - fields: data.fields, - }); + await saveFieldsData(data); // Clear all field data from localStorage for (let i = 0; i < localStorage.length; i++) { @@ -277,24 +298,60 @@ export const DocumentEditForm = ({ } }; - const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { + const onAddFieldsFormAutoSave = async (data: TAddFieldsFormSchema) => { + try { + await saveFieldsData(data); + // Don't clear localStorage on auto-save, only on explicit submit + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while auto-saving the fields.`), + variant: 'destructive', + }); + } + }; + + const saveSubjectData = async (data: TAddSubjectFormSchema) => { const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } = data.meta; - try { - await sendDocument({ - documentId: document.id, - meta: { - subject, - message, - distributionMethod, - emailId, - emailReplyTo: emailReplyTo || null, - emailSettings: emailSettings, - }, - }); + return updateDocument({ + documentId: document.id, + meta: { + subject, + message, + distributionMethod, + emailId, + emailReplyTo, + emailSettings: emailSettings, + }, + }); + }; - if (distributionMethod === DocumentDistributionMethod.EMAIL) { + const sendDocumentWithSubject = async (data: TAddSubjectFormSchema) => { + const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } = + data.meta; + + return sendDocument({ + documentId: document.id, + meta: { + subject, + message, + distributionMethod, + emailId, + emailReplyTo: emailReplyTo || null, + emailSettings, + }, + }); + }; + + const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { + try { + await sendDocumentWithSubject(data); + + if (data.meta.distributionMethod === DocumentDistributionMethod.EMAIL) { toast({ title: _(msg`Document sent`), description: _(msg`Your document has been sent successfully.`), @@ -322,6 +379,21 @@ export const DocumentEditForm = ({ } }; + const onAddSubjectFormAutoSave = async (data: TAddSubjectFormSchema) => { + try { + // Save form data without sending the document + await saveSubjectData(data); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while auto-saving the subject form.`), + variant: 'destructive', + }); + } + }; + const currentDocumentFlow = documentFlow[step]; /** @@ -367,25 +439,28 @@ export const DocumentEditForm = ({ fields={fields} isDocumentPdfLoaded={isDocumentPdfLoaded} onSubmit={onAddSettingsFormSubmit} + onAutoSave={onAddSettingsFormAutoSave} /> @@ -397,6 +472,7 @@ export const DocumentEditForm = ({ recipients={recipients} fields={fields} onSubmit={onAddSubjectFormSubmit} + onAutoSave={onAddSubjectFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} /> diff --git a/apps/remix/app/components/general/document/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx index 55f6d85c2..e5fea4d2b 100644 --- a/apps/remix/app/components/general/document/document-page-view-button.tsx +++ b/apps/remix/app/components/general/document/document-page-view-button.tsx @@ -42,7 +42,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps const onDownloadClick = async () => { try { - const documentWithData = await trpcClient.document.getDocumentById.query( + const documentWithData = await trpcClient.document.get.query( { documentId: document.id, }, diff --git a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx index c4043de3a..326c7553c 100644 --- a/apps/remix/app/components/general/document/document-page-view-dropdown.tsx +++ b/apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -71,7 +71,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP const onDownloadClick = async () => { try { - const documentWithData = await trpcClient.document.getDocumentById.query( + const documentWithData = await trpcClient.document.get.query( { documentId: document.id, }, @@ -100,7 +100,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP const onDownloadOriginalClick = async () => { try { - const documentWithData = await trpcClient.document.getDocumentById.query( + const documentWithData = await trpcClient.document.get.query( { documentId: document.id, }, diff --git a/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx b/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx index 10beae93b..abeeacbc4 100644 --- a/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx +++ b/apps/remix/app/components/general/document/document-page-view-recent-activity.tsx @@ -32,7 +32,7 @@ export const DocumentPageViewRecentActivity = ({ hasNextPage, fetchNextPage, isFetchingNextPage, - } = trpc.document.findDocumentAuditLogs.useInfiniteQuery( + } = trpc.document.auditLog.find.useInfiniteQuery( { documentId, filterForRecentActivity: true, diff --git a/apps/remix/app/components/general/document/document-upload.tsx b/apps/remix/app/components/general/document/document-upload.tsx index c21fcc5f0..b86a12ecc 100644 --- a/apps/remix/app/components/general/document/document-upload.tsx +++ b/apps/remix/app/components/general/document/document-upload.tsx @@ -52,7 +52,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp const [isLoading, setIsLoading] = useState(false); - const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation(); + const { mutateAsync: createDocument } = trpc.document.create.useMutation(); const disabledMessage = useMemo(() => { if (organisation.subscription && remaining.documents === 0) { diff --git a/apps/remix/app/components/general/legacy-field-warning-popover.tsx b/apps/remix/app/components/general/legacy-field-warning-popover.tsx index 6bd489c27..3165b1be7 100644 --- a/apps/remix/app/components/general/legacy-field-warning-popover.tsx +++ b/apps/remix/app/components/general/legacy-field-warning-popover.tsx @@ -28,7 +28,7 @@ export const LegacyFieldWarningPopover = ({ const { mutateAsync: updateTemplate, isPending: isUpdatingTemplate } = trpc.template.updateTemplate.useMutation(); const { mutateAsync: updateDocument, isPending: isUpdatingDocument } = - trpc.document.updateDocument.useMutation(); + trpc.document.update.useMutation(); const onUpdateFieldsClick = async () => { if (type === 'document') { diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 3cea126c8..17d7a45a1 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -124,32 +124,36 @@ export const TemplateEditForm = ({ }, }); - const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { + const saveSettingsData = async (data: TAddTemplateSettingsFormSchema) => { const { signatureTypes } = data.meta; const parsedGlobalAccessAuth = z .array(ZDocumentAccessAuthTypesSchema) .safeParse(data.globalAccessAuth); + return updateTemplateSettings({ + templateId: template.id, + data: { + title: data.title, + externalId: data.externalId || null, + visibility: data.visibility, + globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [], + globalActionAuth: data.globalActionAuth ?? [], + }, + meta: { + ...data.meta, + emailReplyTo: data.meta.emailReplyTo || null, + typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), + uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), + drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), + language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined, + }, + }); + }; + + const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { try { - await updateTemplateSettings({ - templateId: template.id, - data: { - title: data.title, - externalId: data.externalId || null, - visibility: data.visibility, - globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [], - globalActionAuth: data.globalActionAuth ?? [], - }, - meta: { - ...data.meta, - emailReplyTo: data.meta.emailReplyTo || null, - typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), - uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), - drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), - language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined, - }, - }); + await saveSettingsData(data); setStep('signers'); } catch (err) { @@ -163,24 +167,42 @@ export const TemplateEditForm = ({ } }; + const onAddSettingsFormAutoSave = async (data: TAddTemplateSettingsFormSchema) => { + try { + await saveSettingsData(data); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while auto-saving the template settings.`), + variant: 'destructive', + }); + } + }; + + const saveTemplatePlaceholderData = async (data: TAddTemplatePlacholderRecipientsFormSchema) => { + return Promise.all([ + updateTemplateSettings({ + templateId: template.id, + meta: { + signingOrder: data.signingOrder, + allowDictateNextSigner: data.allowDictateNextSigner, + }, + }), + + setRecipients({ + templateId: template.id, + recipients: data.signers, + }), + ]); + }; + const onAddTemplatePlaceholderFormSubmit = async ( data: TAddTemplatePlacholderRecipientsFormSchema, ) => { try { - await Promise.all([ - updateTemplateSettings({ - templateId: template.id, - meta: { - signingOrder: data.signingOrder, - allowDictateNextSigner: data.allowDictateNextSigner, - }, - }), - - setRecipients({ - templateId: template.id, - recipients: data.signers, - }), - ]); + await saveTemplatePlaceholderData(data); setStep('fields'); } catch (err) { @@ -192,12 +214,46 @@ export const TemplateEditForm = ({ } }; + const onAddTemplatePlaceholderFormAutoSave = async ( + data: TAddTemplatePlacholderRecipientsFormSchema, + ) => { + try { + await saveTemplatePlaceholderData(data); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while auto-saving the template placeholders.`), + variant: 'destructive', + }); + } + }; + + const saveFieldsData = async (data: TAddTemplateFieldsFormSchema) => { + return addTemplateFields({ + templateId: template.id, + fields: data.fields, + }); + }; + + const onAddFieldsFormAutoSave = async (data: TAddTemplateFieldsFormSchema) => { + try { + await saveFieldsData(data); + } catch (err) { + console.error(err); + + toast({ + title: _(msg`Error`), + description: _(msg`An error occurred while auto-saving the template fields.`), + variant: 'destructive', + }); + } + }; + const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => { try { - await addTemplateFields({ - templateId: template.id, - fields: data.fields, - }); + await saveFieldsData(data); // Clear all field data from localStorage for (let i = 0; i < localStorage.length; i++) { @@ -270,11 +326,12 @@ export const TemplateEditForm = ({ recipients={recipients} fields={fields} onSubmit={onAddSettingsFormSubmit} + onAutoSave={onAddSettingsFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} /> diff --git a/apps/remix/app/components/general/template/template-page-view-documents-table.tsx b/apps/remix/app/components/general/template/template-page-view-documents-table.tsx index bef9189d5..6072a8846 100644 --- a/apps/remix/app/components/general/template/template-page-view-documents-table.tsx +++ b/apps/remix/app/components/general/template/template-page-view-documents-table.tsx @@ -67,7 +67,7 @@ export const TemplatePageViewDocumentsTable = ({ Object.fromEntries(searchParams ?? []), ); - const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery( + const { data, isLoading, isLoadingError } = trpc.document.find.useQuery( { templateId, page: parsedSearchParams.page, diff --git a/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx b/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx index e0f3f67c9..9b39a27a8 100644 --- a/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx +++ b/apps/remix/app/components/general/template/template-page-view-recent-activity.tsx @@ -18,7 +18,7 @@ export const TemplatePageViewRecentActivity = ({ templateId, documentRootPath, }: TemplatePageViewRecentActivityProps) => { - const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({ + const { data, isLoading, isLoadingError, refetch } = trpc.document.find.useQuery({ templateId, orderByColumn: 'createdAt', orderByDirection: 'asc', diff --git a/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx b/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx index 58e25b179..89a9366b1 100644 --- a/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx +++ b/apps/remix/app/components/tables/admin-document-recipient-item-table.tsx @@ -52,7 +52,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp }, }); - const { mutateAsync: updateRecipient } = trpc.admin.updateRecipient.useMutation(); + const { mutateAsync: updateRecipient } = trpc.admin.recipient.update.useMutation(); const columns = useMemo(() => { return [ diff --git a/apps/remix/app/components/tables/document-logs-table.tsx b/apps/remix/app/components/tables/document-logs-table.tsx index 8cdae26d5..a042c6a44 100644 --- a/apps/remix/app/components/tables/document-logs-table.tsx +++ b/apps/remix/app/components/tables/document-logs-table.tsx @@ -34,7 +34,7 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => { const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); - const { data, isLoading, isLoadingError } = trpc.document.findDocumentAuditLogs.useQuery( + const { data, isLoading, isLoadingError } = trpc.document.auditLog.find.useQuery( { documentId, page: parsedSearchParams.page, diff --git a/apps/remix/app/components/tables/documents-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx index 1333ca912..c1d68c133 100644 --- a/apps/remix/app/components/tables/documents-table-action-button.tsx +++ b/apps/remix/app/components/tables/documents-table-action-button.tsx @@ -45,7 +45,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr const onDownloadClick = async () => { try { const document = !recipient - ? await trpcClient.document.getDocumentById.query( + ? await trpcClient.document.get.query( { documentId: row.id, }, diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx index 1186afb18..8114c6cc1 100644 --- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx @@ -77,7 +77,7 @@ export const DocumentsTableActionDropdown = ({ const onDownloadClick = async () => { try { const document = !recipient - ? await trpcClient.document.getDocumentById.query({ + ? await trpcClient.document.get.query({ documentId: row.id, }) : await trpcClient.document.getDocumentByToken.query({ @@ -103,7 +103,7 @@ export const DocumentsTableActionDropdown = ({ const onDownloadOriginalClick = async () => { try { const document = !recipient - ? await trpcClient.document.getDocumentById.query({ + ? await trpcClient.document.get.query({ documentId: row.id, }) : await trpcClient.document.getDocumentByToken.query({ diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx index fa5be7d2d..a003f4d0d 100644 --- a/apps/remix/app/components/tables/documents-table.tsx +++ b/apps/remix/app/components/tables/documents-table.tsx @@ -11,7 +11,7 @@ import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-upda import { useSession } from '@documenso/lib/client-only/providers/session'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; -import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema'; +import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx index 45f837c17..f2d138e0d 100644 --- a/apps/remix/app/components/tables/inbox-table.tsx +++ b/apps/remix/app/components/tables/inbox-table.tsx @@ -17,7 +17,6 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types'; -import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema'; import { Button } from '@documenso/ui/primitives/button'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; @@ -32,12 +31,12 @@ import { useOptionalCurrentTeam } from '~/providers/team'; import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip'; export type DocumentsTableProps = { - data?: TFindDocumentsResponse; + data?: TFindInboxResponse; isLoading?: boolean; isLoadingError?: boolean; }; -type DocumentsTableRow = TFindDocumentsResponse['data'][number]; +type DocumentsTableRow = TFindInboxResponse['data'][number]; export const InboxTable = () => { const { _, i18n } = useLingui(); diff --git a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx index e86800149..835bebf55 100644 --- a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx +++ b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx @@ -62,7 +62,7 @@ export const SettingsSecurityPasskeyTableActions = ({ }); const { mutateAsync: updatePasskey, isPending: isUpdatingPasskey } = - trpc.auth.updatePasskey.useMutation({ + trpc.auth.passkey.update.useMutation({ onSuccess: () => { toast({ title: _(msg`Success`), @@ -84,7 +84,7 @@ export const SettingsSecurityPasskeyTableActions = ({ }); const { mutateAsync: deletePasskey, isPending: isDeletingPasskey } = - trpc.auth.deletePasskey.useMutation({ + trpc.auth.passkey.delete.useMutation({ onSuccess: () => { toast({ title: _(msg`Success`), diff --git a/apps/remix/app/components/tables/settings-security-passkey-table.tsx b/apps/remix/app/components/tables/settings-security-passkey-table.tsx index 3d202900a..b2fe09621 100644 --- a/apps/remix/app/components/tables/settings-security-passkey-table.tsx +++ b/apps/remix/app/components/tables/settings-security-passkey-table.tsx @@ -26,7 +26,7 @@ export const SettingsSecurityPasskeyTable = () => { const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); - const { data, isLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery( + const { data, isLoading, isLoadingError } = trpc.auth.passkey.find.useQuery( { page: parsedSearchParams.page, perPage: parsedSearchParams.perPage, diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx index db1b4d0e8..623ac0938 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -48,7 +48,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component const { toast } = useToast(); const { mutate: resealDocument, isPending: isResealDocumentLoading } = - trpc.admin.resealDocument.useMutation({ + trpc.admin.document.reseal.useMutation({ onSuccess: () => { toast({ title: _(msg`Success`), diff --git a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx index eb659ce4a..256cc4fc7 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx @@ -33,7 +33,7 @@ export default function AdminDocumentsPage() { const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined; const { data: findDocumentsData, isPending: isFindDocumentsLoading } = - trpc.admin.findDocuments.useQuery( + trpc.admin.document.find.useQuery( { query: debouncedTerm, page: page || 1, diff --git a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx index 458553dae..3cd0f5853 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx @@ -2,14 +2,14 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { User } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { useRevalidator } from 'react-router'; import { Link } from 'react-router'; import type { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; -import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-router/schema'; +import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types'; +import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types'; import { Button } from '@documenso/ui/primitives/button'; import { Form, @@ -33,12 +33,12 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox'; -const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true }); +const ZUserFormSchema = ZUpdateUserRequestSchema.omit({ id: true }); type TUserFormSchema = z.infer; export default function UserPage({ params }: { params: { id: number } }) { - const { data: user, isLoading: isLoadingUser } = trpc.profile.getUser.useQuery( + const { data: user, isLoading: isLoadingUser } = trpc.admin.user.get.useQuery( { id: Number(params.id), }, @@ -78,14 +78,14 @@ export default function UserPage({ params }: { params: { id: number } }) { return ; } -const AdminUserPage = ({ user }: { user: User }) => { +const AdminUserPage = ({ user }: { user: TGetUserResponse }) => { const { _ } = useLingui(); const { toast } = useToast(); const { revalidate } = useRevalidator(); const roles = user.roles ?? []; - const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation(); + const { mutateAsync: updateUserMutation } = trpc.admin.user.update.useMutation(); const form = useForm({ resolver: zodResolver(ZUserFormSchema), diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx index 75592696c..84c9c6883 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx @@ -67,6 +67,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { const documentVisibility = document?.visibility; const currentTeamMemberRole = team.currentTeamRole; const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email); + let canAccessDocument = true; if (!isRecipient && document?.userId !== user.id) { diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx index 56a01bb90..b27ded2bb 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -50,10 +50,6 @@ export async function loader({ params, request }: Route.LoaderArgs) { throw redirect(documentRootPath); } - if (document.folderId) { - throw redirect(documentRootPath); - } - const recipients = await getRecipientsForDocument({ documentId, userId: user.id, @@ -68,13 +64,13 @@ export async function loader({ params, request }: Route.LoaderArgs) { return { document, - documentRootPath, recipients, + documentRootPath, }; } export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) { - const { document, documentRootPath, recipients } = loaderData; + const { document, recipients, documentRootPath } = loaderData; const { _, i18n } = useLingui(); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index 02ae85896..eac87bae7 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -12,10 +12,8 @@ import { parseToIntegerArray } from '@documenso/lib/utils/params'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { trpc } from '@documenso/trpc/react'; -import { - type TFindDocumentsInternalResponse, - ZFindDocumentsInternalRequestSchema, -} from '@documenso/trpc/server/document-router/schema'; +import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; +import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx index 1d5f86bd5..584a71197 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.tokens.tsx @@ -21,7 +21,7 @@ export function meta() { export default function ApiTokensPage() { const { i18n } = useLingui(); - const { data: tokens } = trpc.apiToken.getTokens.useQuery(); + const { data: tokens } = trpc.apiToken.getMany.useQuery(); const team = useOptionalCurrentTeam(); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx index bd9f0de99..56e046f13 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx @@ -9,10 +9,10 @@ import { trpc } from '@documenso/trpc/react'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; import { FolderGrid } from '~/components/general/folder/folder-grid'; +import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper'; import { TemplatesTable } from '~/components/tables/templates-table'; import { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; -import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper'; export function meta() { return appMetaTags('Templates'); diff --git a/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx b/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx index 5fa253ca3..39acc2194 100644 --- a/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx +++ b/apps/remix/app/routes/_unauthenticated+/organisation.invite.$token.tsx @@ -45,6 +45,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { mode: 'insensitive', }, }, + select: { + id: true, + }, }); // Directly convert the team member invite to a team member if they already have an account. diff --git a/apps/remix/package.json b/apps/remix/package.json index a0c20d515..a97fff5f3 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -101,5 +101,5 @@ "vite-plugin-babel-macros": "^1.0.6", "vite-tsconfig-paths": "^5.1.4" }, - "version": "1.12.2-rc.4" + "version": "1.12.2-rc.6" } diff --git a/docker/testing/compose.yml b/docker/testing/compose.yml index 28ec055c1..110e9da6b 100644 --- a/docker/testing/compose.yml +++ b/docker/testing/compose.yml @@ -51,4 +51,4 @@ services: ports: - 3000:3000 volumes: - - ../../apps/web/example/cert.p12:/opt/documenso/cert.p12 + - ../../apps/remix/example/cert.p12:/opt/documenso/cert.p12 diff --git a/package-lock.json b/package-lock.json index 7b2d0ed69..647e4c43c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "1.12.2-rc.4", + "version": "1.12.2-rc.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "1.12.2-rc.4", + "version": "1.12.2-rc.6", "workspaces": [ "apps/*", "packages/*" @@ -89,7 +89,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "1.12.2-rc.4", + "version": "1.12.2-rc.6", "dependencies": { "@documenso/api": "*", "@documenso/assets": "*", diff --git a/package.json b/package.json index 1f1a39496..9e6654c0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "1.12.2-rc.4", + "version": "1.12.2-rc.6", "scripts": { "build": "turbo run build", "dev": "turbo run dev --filter=@documenso/remix", diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index e8afd9af4..08556154a 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -34,6 +34,7 @@ import { createTemplate } from '@documenso/lib/server-only/template/create-templ import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; import { findTemplates } from '@documenso/lib/server-only/template/find-templates'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; +import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email'; import { ZCheckboxFieldMeta, @@ -330,6 +331,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { userId: user.id, teamId: team?.id, formValues: body.formValues, + folderId: body.folderId, documentDataId: documentData.id, requestMetadata: metadata, }); @@ -736,6 +738,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { teamId: team?.id, recipients: body.recipients, prefillFields: body.prefillFields, + folderId: body.folderId, override: { title: body.title, ...body.meta, @@ -978,10 +981,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { userId: user.id, teamId: team?.id, recipients: [ - ...recipients.map(({ email, name }) => ({ - email, - name, - role, + ...recipients.map((recipient) => ({ + email: recipient.email, + name: recipient.name, + role: recipient.role, + signingOrder: recipient.signingOrder, + actionAuth: ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? [], })), { email, diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index 81f1b7a79..b8e31bfe0 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -33,7 +33,7 @@ export const ZNoBodyMutationSchema = null; */ export const ZGetDocumentsQuerySchema = z.object({ page: z.coerce.number().min(1).optional().default(1), - perPage: z.coerce.number().min(1).optional().default(1), + perPage: z.coerce.number().min(1).optional().default(10), }); export type TGetDocumentsQuerySchema = z.infer; @@ -136,6 +136,12 @@ export type TUploadDocumentSuccessfulSchema = z.infer { + const { user, team } = await seedUser(); + const document = await seedBlankDocument(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + + await page.getByRole('button', { name: 'Add signer' }).click(); + + await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com'); + await page.getByPlaceholder('Name').nth(1).fill('Recipient 2'); + + await page.getByRole('button', { name: 'Continue' }).click(); + + return { user, team, document }; +}; + +const triggerAutosave = async (page: Page) => { + await page.locator('#document-flow-form-container').click(); + await page.locator('#document-flow-form-container').blur(); + + await page.waitForTimeout(5000); +}; + +test.describe('AutoSave Fields Step', () => { + test('should autosave the fields without advanced settings', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page); + + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Text' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 200, + }, + }); + + await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' }); + + await page + .getByTestId('field-advanced-settings-footer') + .getByRole('button', { name: 'Cancel' }) + .click(); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 500, + }, + }); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedFields = await getFieldsForDocument({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedFields.length).toBe(3); + expect(retrievedFields[0].type).toBe('SIGNATURE'); + expect(retrievedFields[1].type).toBe('TEXT'); + expect(retrievedFields[2].type).toBe('SIGNATURE'); + }).toPass(); + }); + + test('should autosave the field deletion', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page); + + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Text' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 200, + }, + }); + + await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' }); + + await page + .getByTestId('field-advanced-settings-footer') + .getByRole('button', { name: 'Cancel' }) + .click(); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 500, + }, + }); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); + + await page.getByText('Text').nth(1).click(); + await page.getByRole('button', { name: 'Remove' }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedFields = await getFieldsForDocument({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedFields.length).toBe(2); + expect(retrievedFields[0].type).toBe('SIGNATURE'); + expect(retrievedFields[1].type).toBe('SIGNATURE'); + }).toPass(); + }); + + test('should autosave the field duplication', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page); + + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Text' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 200, + }, + }); + + await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' }); + + await page + .getByTestId('field-advanced-settings-footer') + .getByRole('button', { name: 'Cancel' }) + .click(); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 500, + }, + }); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); + + await page.getByText('Signature').nth(1).click(); + await page.getByRole('button', { name: 'Duplicate', exact: true }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedFields = await getFieldsForDocument({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedFields.length).toBe(4); + expect(retrievedFields[0].type).toBe('SIGNATURE'); + expect(retrievedFields[1].type).toBe('TEXT'); + expect(retrievedFields[2].type).toBe('SIGNATURE'); + expect(retrievedFields[3].type).toBe('SIGNATURE'); + }).toPass(); + }); + + test('should autosave the fields with advanced settings', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToFieldsStep(page); + + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Text' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 200, + }, + }); + + await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field'); + await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder'); + await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text'); + + await page + .getByTestId('field-advanced-settings-footer') + .getByRole('button', { name: 'Save' }) + .click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedFields = await getFieldsForDocument({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedFields.length).toBe(2); + expect(retrievedFields[0].type).toBe('SIGNATURE'); + expect(retrievedFields[1].type).toBe('TEXT'); + + const textField = retrievedFields[1]; + expect(textField.fieldMeta).toBeDefined(); + + if ( + textField.fieldMeta && + typeof textField.fieldMeta === 'object' && + 'type' in textField.fieldMeta + ) { + expect(textField.fieldMeta.type).toBe('text'); + expect(textField.fieldMeta.label).toBe('Test Field'); + expect(textField.fieldMeta.placeholder).toBe('Test Placeholder'); + + if (textField.fieldMeta.type === 'text') { + expect(textField.fieldMeta.text).toBe('Test Text'); + } + } else { + throw new Error('fieldMeta should be defined and contain advanced settings'); + } + }).toPass(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts new file mode 100644 index 000000000..e34f2c104 --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts @@ -0,0 +1,243 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + +const setupDocument = async (page: Page) => { + const { user, team } = await seedUser(); + + const document = await seedBlankDocument(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + return { user, team, document }; +}; + +const triggerAutosave = async (page: Page) => { + await page.locator('#document-flow-form-container').click(); + await page.locator('#document-flow-form-container').blur(); + + await page.waitForTimeout(5000); +}; + +test.describe('AutoSave Settings Step', () => { + test('should autosave the title change', async ({ page }) => { + const { user, document, team } = await setupDocument(page); + + const newDocumentTitle = 'New Document Title'; + + await page.getByRole('textbox', { name: 'Title *' }).fill(newDocumentTitle); + + await triggerAutosave(page); + + await expect(async () => { + const retrieved = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue(retrieved.title); + }).toPass(); + }); + + test('should autosave the language change', async ({ page }) => { + const { user, document, team } = await setupDocument(page); + + const newDocumentLanguage = 'French'; + const expectedLanguageCode = 'fr'; + + await page.getByRole('combobox').first().click(); + await page.getByRole('option', { name: newDocumentLanguage }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrieved = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrieved.documentMeta?.language).toBe(expectedLanguageCode); + }).toPass(); + }); + + test('should autosave the document access change', async ({ page }) => { + const { user, document, team } = await setupDocument(page); + + const access = 'Require account'; + const accessValue = 'ACCOUNT'; + + await page.getByRole('combobox').nth(1).click(); + await page.getByRole('option', { name: access }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrieved = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrieved.authOptions?.globalAccessAuth).toContain(accessValue); + }).toPass(); + }); + + test('should autosave the external ID change', async ({ page }) => { + const { user, document, team } = await setupDocument(page); + + const newExternalId = '1234567890'; + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + + await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId); + + await triggerAutosave(page); + + await expect(async () => { + const retrieved = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrieved.externalId).toBe(newExternalId); + }).toPass(); + }); + + test('should autosave the allowed signature types change', async ({ page }) => { + const { user, document, team } = await setupDocument(page); + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + + await page.getByRole('combobox').nth(3).click(); + await page.getByRole('option', { name: 'Draw' }).click(); + await page.getByRole('option', { name: 'Type' }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrieved = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false); + expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false); + expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true); + }).toPass(); + }); + + test('should autosave the date format change', async ({ page }) => { + const { user, document, team } = await setupDocument(page); + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + + await page.getByRole('combobox').nth(4).click(); + await page.getByRole('option', { name: 'ISO 8601', exact: true }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrieved = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrieved.documentMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"); + }).toPass(); + }); + + test('should autosave the timezone change', async ({ page }) => { + const { user, document, team } = await setupDocument(page); + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + + await page.getByRole('combobox').nth(5).click(); + await page.getByRole('option', { name: 'Europe/London' }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrieved = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrieved.documentMeta?.timezone).toBe('Europe/London'); + }).toPass(); + }); + + test('should autosave the redirect URL change', async ({ page }) => { + const { user, document, team } = await setupDocument(page); + + const newRedirectUrl = 'https://documenso.com/test/'; + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + + await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl); + + await triggerAutosave(page); + + await expect(async () => { + const retrieved = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrieved.documentMeta?.redirectUrl).toBe(newRedirectUrl); + }).toPass(); + }); + + test('should autosave multiple field changes together', async ({ page }) => { + const { user, document, team } = await setupDocument(page); + + const newTitle = 'Updated Document Title'; + await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle); + + await page.getByRole('combobox').first().click(); + await page.getByRole('option', { name: 'German' }).click(); + + await page.getByRole('combobox').nth(1).click(); + await page.getByRole('option', { name: 'Require account' }).click(); + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + const newExternalId = 'MULTI-TEST-123'; + await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId); + + await page.getByRole('combobox').nth(5).click(); + await page.getByRole('option', { name: 'Europe/Berlin' }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrieved = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrieved.title).toBe(newTitle); + expect(retrieved.documentMeta?.language).toBe('de'); + expect(retrieved.authOptions?.globalAccessAuth).toContain('ACCOUNT'); + expect(retrieved.externalId).toBe(newExternalId); + expect(retrieved.documentMeta?.timezone).toBe('Europe/Berlin'); + }).toPass(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts new file mode 100644 index 000000000..e4d255750 --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts @@ -0,0 +1,168 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + +const setupDocumentAndNavigateToSignersStep = async (page: Page) => { + const { user, team } = await seedUser(); + const document = await seedBlankDocument(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + await page.getByRole('button', { name: 'Continue' }).click(); + + return { user, team, document }; +}; + +const triggerAutosave = async (page: Page) => { + await page.locator('#document-flow-form-container').click(); + await page.locator('#document-flow-form-container').blur(); + + await page.waitForTimeout(5000); +}; + +const addSignerAndSave = async (page: Page) => { + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + + await triggerAutosave(page); +}; + +test.describe('AutoSave Signers Step', () => { + test('should autosave the signers addition', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page); + + await addSignerAndSave(page); + + await expect(async () => { + const retrievedRecipients = await getRecipientsForDocument({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedRecipients.length).toBe(1); + expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com'); + expect(retrievedRecipients[0].name).toBe('Recipient 1'); + }).toPass(); + }); + + test('should autosave the signer deletion', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page); + + await addSignerAndSave(page); + + await page.getByRole('button', { name: 'Add myself' }).click(); + await triggerAutosave(page); + + await page.getByTestId('remove-signer-button').first().click(); + await triggerAutosave(page); + + await expect(async () => { + const retrievedRecipients = await getRecipientsForDocument({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedRecipients.length).toBe(1); + expect(retrievedRecipients[0].email).toBe(user.email); + expect(retrievedRecipients[0].name).toBe(user.name); + }).toPass(); + }); + + test('should autosave the signer update', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page); + + await addSignerAndSave(page); + + await page.getByPlaceholder('Name').fill('Documenso Manager'); + await page.getByPlaceholder('Email').fill('manager@documenso.com'); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Receives copy' }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedRecipients = await getRecipientsForDocument({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedRecipients.length).toBe(1); + expect(retrievedRecipients[0].email).toBe('manager@documenso.com'); + expect(retrievedRecipients[0].name).toBe('Documenso Manager'); + expect(retrievedRecipients[0].role).toBe('CC'); + }).toPass(); + }); + + test('should autosave the signing order change', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToSignersStep(page); + + await addSignerAndSave(page); + + await page.getByRole('button', { name: 'Add signer' }).click(); + + await page.getByTestId('signer-email-input').nth(1).fill('recipient2@documenso.com'); + await page.getByLabel('Name').nth(1).fill('Recipient 2'); + + await page.getByRole('button', { name: 'Add Signer' }).click(); + + await page.getByTestId('signer-email-input').nth(2).fill('recipient3@documenso.com'); + await page.getByLabel('Name').nth(2).fill('Recipient 3'); + + await triggerAutosave(page); + + await page.getByLabel('Enable signing order').check(); + await page.getByLabel('Allow signers to dictate next signer').check(); + await triggerAutosave(page); + + await page.getByTestId('signing-order-input').nth(0).fill('3'); + await page.getByTestId('signing-order-input').nth(0).blur(); + await triggerAutosave(page); + + await page.getByTestId('signing-order-input').nth(1).fill('1'); + await page.getByTestId('signing-order-input').nth(1).blur(); + await triggerAutosave(page); + + await page.getByTestId('signing-order-input').nth(2).fill('2'); + await page.getByTestId('signing-order-input').nth(2).blur(); + await triggerAutosave(page); + + await expect(async () => { + const retrievedDocumentData = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + const retrievedRecipients = await getRecipientsForDocument({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedDocumentData.documentMeta?.signingOrder).toBe('SEQUENTIAL'); + expect(retrievedDocumentData.documentMeta?.allowDictateNextSigner).toBe(true); + expect(retrievedRecipients.length).toBe(3); + expect(retrievedRecipients[0].signingOrder).toBe(2); + expect(retrievedRecipients[1].signingOrder).toBe(3); + expect(retrievedRecipients[2].signingOrder).toBe(1); + }).toPass(); + }); +}); diff --git a/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts new file mode 100644 index 000000000..270a31d8e --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts @@ -0,0 +1,200 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + +export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => { + const { user, team } = await seedUser(); + const document = await seedBlankDocument(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page.getByRole('heading', { name: 'Distribute Document' })).toBeVisible(); + + return { user, team, document }; +}; + +export const triggerAutosave = async (page: Page) => { + await page.locator('#document-flow-form-container').click(); + await page.locator('#document-flow-form-container').blur(); + + await page.waitForTimeout(5000); +}; + +test.describe('AutoSave Subject Step', () => { + test('should autosave the subject field', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page); + + const subject = 'Hello world!'; + + await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedDocumentData = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue( + retrievedDocumentData.documentMeta?.subject ?? '', + ); + }).toPass(); + }); + + test('should autosave the message field', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page); + + const message = 'Please review and sign this important document. Thank you!'; + + await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedDocumentData = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue( + retrievedDocumentData.documentMeta?.message ?? '', + ); + }).toPass(); + }); + + test('should autosave the email settings checkboxes', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page); + + // Toggle some email settings checkboxes (randomly - some checked, some unchecked) + await page.getByText('Send recipient signed email').click(); + await page.getByText('Send recipient removed email').click(); + await page.getByText('Send document completed email', { exact: true }).click(); + await page.getByText('Send document deleted email').click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedDocumentData = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + const emailSettings = retrievedDocumentData.documentMeta?.emailSettings; + + await expect(page.getByText('Send recipient signed email')).toBeChecked({ + checked: emailSettings?.recipientSigned, + }); + await expect(page.getByText('Send recipient removed email')).toBeChecked({ + checked: emailSettings?.recipientRemoved, + }); + await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({ + checked: emailSettings?.documentCompleted, + }); + await expect(page.getByText('Send document deleted email')).toBeChecked({ + checked: emailSettings?.documentDeleted, + }); + + await expect(page.getByText('Send recipient signing request email')).toBeChecked({ + checked: emailSettings?.recipientSigningRequest, + }); + await expect(page.getByText('Send document pending email')).toBeChecked({ + checked: emailSettings?.documentPending, + }); + await expect(page.getByText('Send document completed email to the owner')).toBeChecked({ + checked: emailSettings?.ownerDocumentCompleted, + }); + }).toPass(); + }); + + test('should autosave all fields and settings together', async ({ page }) => { + const { user, document, team } = await setupDocumentAndNavigateToSubjectStep(page); + + const subject = 'Combined Test Subject - Please Sign'; + const message = + 'This is a comprehensive test message for autosave functionality. Please review and sign at your earliest convenience.'; + + await page.getByRole('textbox', { name: 'Subject (Optional)' }).fill(subject); + await page.getByRole('textbox', { name: 'Message (Optional)' }).fill(message); + + await page.getByText('Send recipient signed email').click(); + await page.getByText('Send recipient removed email').click(); + await page.getByText('Send document completed email', { exact: true }).click(); + await page.getByText('Send document deleted email').click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedDocumentData = await getDocumentById({ + documentId: document.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedDocumentData.documentMeta?.subject).toBe(subject); + expect(retrievedDocumentData.documentMeta?.message).toBe(message); + expect(retrievedDocumentData.documentMeta?.emailSettings).toBeDefined(); + + await expect(page.getByRole('textbox', { name: 'Subject (Optional)' })).toHaveValue( + retrievedDocumentData.documentMeta?.subject ?? '', + ); + await expect(page.getByRole('textbox', { name: 'Message (Optional)' })).toHaveValue( + retrievedDocumentData.documentMeta?.message ?? '', + ); + + await expect(page.getByText('Send recipient signed email')).toBeChecked({ + checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigned, + }); + await expect(page.getByText('Send recipient removed email')).toBeChecked({ + checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientRemoved, + }); + await expect(page.getByText('Send document completed email', { exact: true })).toBeChecked({ + checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted, + }); + await expect(page.getByText('Send document deleted email')).toBeChecked({ + checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted, + }); + + await expect(page.getByText('Send recipient signing request email')).toBeChecked({ + checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest, + }); + await expect(page.getByText('Send document pending email')).toBeChecked({ + checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending, + }); + await expect(page.getByText('Send document completed email to the owner')).toBeChecked({ + checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted, + }); + }).toPass(); + }); +}); 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 579e21e26..1e1a70288 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -534,9 +534,6 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip await page.getByLabel('Title').fill(documentTitle); await page.getByRole('button', { name: 'Continue' }).click(); - await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); - await page.getByLabel('Enable signing order').check(); - for (let i = 1; i <= 3; i++) { if (i > 1) { await page.getByRole('button', { name: 'Add Signer' }).click(); @@ -558,6 +555,9 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip .fill(`User ${i}`); } + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + await page.getByLabel('Enable signing order').check(); + await page.getByRole('button', { name: 'Continue' }).click(); await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts new file mode 100644 index 000000000..5a167340a --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts @@ -0,0 +1,304 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +const setupTemplateAndNavigateToFieldsStep = async (page: Page) => { + const { user, team } = await seedUser(); + const template = await seedBlankTemplate(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/templates/${template.id}/edit`, + }); + + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + + await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click(); + + await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com'); + await page.getByPlaceholder('Name').nth(1).fill('Recipient 2'); + + await page.getByRole('button', { name: 'Continue' }).click(); + + return { user, team, template }; +}; + +const triggerAutosave = async (page: Page) => { + await page.locator('#document-flow-form-container').click(); + await page.locator('#document-flow-form-container').blur(); + + await page.waitForTimeout(5000); +}; + +test.describe('AutoSave Fields Step', () => { + test('should autosave the fields without advanced settings', async ({ page }) => { + const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page); + + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Text' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 200, + }, + }); + + await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' }); + + await page + .getByTestId('field-advanced-settings-footer') + .getByRole('button', { name: 'Cancel' }) + .click(); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 500, + }, + }); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedFields = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + const fields = retrievedFields.fields; + + expect(fields.length).toBe(3); + expect(fields[0].type).toBe('SIGNATURE'); + expect(fields[1].type).toBe('TEXT'); + expect(fields[2].type).toBe('SIGNATURE'); + }).toPass(); + }); + + test('should autosave the field deletion', async ({ page }) => { + const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page); + + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Text' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 200, + }, + }); + + await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' }); + + await page + .getByTestId('field-advanced-settings-footer') + .getByRole('button', { name: 'Cancel' }) + .click(); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 500, + }, + }); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); + + await page.getByText('Text').nth(1).click(); + await page.getByRole('button', { name: 'Remove' }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedFields = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + const fields = retrievedFields.fields; + + expect(fields.length).toBe(2); + expect(fields[0].type).toBe('SIGNATURE'); + expect(fields[1].type).toBe('SIGNATURE'); + }).toPass(); + }); + + test('should autosave the field duplication', async ({ page }) => { + const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page); + + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Text' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 200, + }, + }); + + await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' }); + + await page + .getByTestId('field-advanced-settings-footer') + .getByRole('button', { name: 'Cancel' }) + .click(); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 500, + }, + }); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click(); + + await page.getByText('Signature').nth(1).click(); + await page.getByRole('button', { name: 'Duplicate', exact: true }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedFields = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + const fields = retrievedFields.fields; + + expect(fields.length).toBe(4); + expect(fields[0].type).toBe('SIGNATURE'); + expect(fields[1].type).toBe('TEXT'); + expect(fields[2].type).toBe('SIGNATURE'); + expect(fields[3].type).toBe('SIGNATURE'); + }).toPass(); + }); + + test('should autosave the fields with advanced settings', async ({ page }) => { + const { user, template, team } = await setupTemplateAndNavigateToFieldsStep(page); + + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Signature' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 100, + }, + }); + + await page.getByRole('button', { name: 'Text' }).click(); + await page.locator('canvas').click({ + position: { + x: 100, + y: 200, + }, + }); + + await page.getByRole('textbox', { name: 'Field label' }).fill('Test Field'); + await page.getByRole('textbox', { name: 'Field placeholder' }).fill('Test Placeholder'); + await page.getByRole('textbox', { name: 'Add text to the field' }).fill('Test Text'); + + await page.getByTestId('field-advanced-settings-footer').waitFor({ state: 'visible' }); + + await page + .getByTestId('field-advanced-settings-footer') + .getByRole('button', { name: 'Save' }) + .click(); + + await page.waitForTimeout(2500); + await triggerAutosave(page); + + await expect(async () => { + const retrievedTemplate = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + const fields = retrievedTemplate.fields; + + expect(fields.length).toBe(2); + expect(fields[0].type).toBe('SIGNATURE'); + expect(fields[1].type).toBe('TEXT'); + + const textField = fields[1]; + expect(textField.fieldMeta).toBeDefined(); + + if ( + textField.fieldMeta && + typeof textField.fieldMeta === 'object' && + 'type' in textField.fieldMeta + ) { + expect(textField.fieldMeta.type).toBe('text'); + expect(textField.fieldMeta.label).toBe('Test Field'); + expect(textField.fieldMeta.placeholder).toBe('Test Placeholder'); + + if (textField.fieldMeta.type === 'text') { + expect(textField.fieldMeta.text).toBe('Test Text'); + } + } else { + throw new Error('fieldMeta should be defined and contain advanced settings'); + } + }).toPass(); + }); +}); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts new file mode 100644 index 000000000..af12e7290 --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts @@ -0,0 +1,244 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + +const setupTemplate = async (page: Page) => { + const { user, team } = await seedUser(); + const template = await seedBlankTemplate(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/templates/${template.id}/edit`, + }); + + return { user, team, template }; +}; + +const triggerAutosave = async (page: Page) => { + await page.locator('#document-flow-form-container').click(); + await page.locator('#document-flow-form-container').blur(); + + await page.waitForTimeout(5000); +}; + +test.describe('AutoSave Settings Step - Templates', () => { + test('should autosave the title change', async ({ page }) => { + const { user, template, team } = await setupTemplate(page); + + const newTemplateTitle = 'New Template Title'; + + await page.getByRole('textbox', { name: 'Title *' }).fill(newTemplateTitle); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedTemplate = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + await expect(page.getByRole('textbox', { name: 'Title *' })).toHaveValue( + retrievedTemplate.title, + ); + }).toPass(); + }); + + test('should autosave the language change', async ({ page }) => { + const { user, template, team } = await setupTemplate(page); + + const newTemplateLanguage = 'French'; + const expectedLanguageCode = 'fr'; + + await page.getByRole('combobox').first().click(); + await page.getByRole('option', { name: newTemplateLanguage }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedTemplate = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedTemplate.templateMeta?.language).toBe(expectedLanguageCode); + }).toPass(); + }); + + test('should autosave the template access change', async ({ page }) => { + const { user, template, team } = await setupTemplate(page); + + const access = 'Require account'; + const accessValue = 'ACCOUNT'; + + await page.getByRole('combobox').nth(1).click(); + await page.getByRole('option', { name: access }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedTemplate = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain(accessValue); + }).toPass(); + }); + + test('should autosave the external ID change', async ({ page }) => { + const { user, template, team } = await setupTemplate(page); + + const newExternalId = '1234567890'; + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + + await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedTemplate = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedTemplate.externalId).toBe(newExternalId); + }).toPass(); + }); + + test('should autosave the allowed signature types change', async ({ page }) => { + const { user, template, team } = await setupTemplate(page); + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + + await page.getByRole('combobox').nth(4).click(); + await page.getByRole('option', { name: 'Draw' }).click(); + await page.getByRole('option', { name: 'Type' }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedTemplate = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false); + expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false); + expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true); + }).toPass(); + }); + + test('should autosave the date format change', async ({ page }) => { + const { user, template, team } = await setupTemplate(page); + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + + await page.getByRole('combobox').nth(5).click(); + await page.getByRole('option', { name: 'ISO 8601', exact: true }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedTemplate = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedTemplate.templateMeta?.dateFormat).toBe("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"); + }).toPass(); + }); + + test('should autosave the timezone change', async ({ page }) => { + const { user, template, team } = await setupTemplate(page); + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + + await page.getByRole('combobox').nth(6).click(); + await page.getByRole('option', { name: 'Europe/London' }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedTemplate = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/London'); + }).toPass(); + }); + + test('should autosave the redirect URL change', async ({ page }) => { + const { user, template, team } = await setupTemplate(page); + + const newRedirectUrl = 'https://documenso.com/test/'; + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + + await page.getByRole('textbox', { name: 'Redirect URL' }).fill(newRedirectUrl); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedTemplate = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedTemplate.templateMeta?.redirectUrl).toBe(newRedirectUrl); + }).toPass(); + }); + + test('should autosave multiple field changes together', async ({ page }) => { + const { user, template, team } = await setupTemplate(page); + + const newTitle = 'Updated Template Title'; + await page.getByRole('textbox', { name: 'Title *' }).fill(newTitle); + + await page.getByRole('combobox').first().click(); + await page.getByRole('option', { name: 'German' }).click(); + + await page.getByRole('combobox').nth(1).click(); + await page.getByRole('option', { name: 'Require account' }).click(); + + await page.getByRole('button', { name: 'Advanced Options' }).click(); + const newExternalId = 'MULTI-TEST-123'; + await page.getByRole('textbox', { name: 'External ID' }).fill(newExternalId); + + await page.getByRole('combobox').nth(6).click(); + await page.getByRole('option', { name: 'Europe/Berlin' }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedTemplate = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedTemplate.title).toBe(newTitle); + expect(retrievedTemplate.templateMeta?.language).toBe('de'); + expect(retrievedTemplate.authOptions?.globalAccessAuth).toContain('ACCOUNT'); + expect(retrievedTemplate.externalId).toBe(newExternalId); + expect(retrievedTemplate.templateMeta?.timezone).toBe('Europe/Berlin'); + }).toPass(); + }); +}); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts new file mode 100644 index 000000000..f5bd07e94 --- /dev/null +++ b/packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts @@ -0,0 +1,174 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template'; +import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +test.describe.configure({ mode: 'parallel', timeout: 60000 }); + +const setupTemplateAndNavigateToSignersStep = async (page: Page) => { + const { user, team } = await seedUser(); + const template = await seedBlankTemplate(user, team.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/templates/${template.id}/edit`, + }); + + await page.getByRole('button', { name: 'Continue' }).click(); + + return { user, team, template }; +}; + +const triggerAutosave = async (page: Page) => { + await page.locator('#document-flow-form-container').click(); + await page.locator('#document-flow-form-container').blur(); + + await page.waitForTimeout(5000); +}; + +const addSignerAndSave = async (page: Page) => { + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + + await triggerAutosave(page); +}; + +test.describe('AutoSave Signers Step - Templates', () => { + test('should autosave the signers addition', async ({ page }) => { + const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page); + + await addSignerAndSave(page); + + await expect(async () => { + const retrievedRecipients = await getRecipientsForTemplate({ + templateId: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedRecipients.length).toBe(1); + expect(retrievedRecipients[0].email).toBe('recipient1@documenso.com'); + expect(retrievedRecipients[0].name).toBe('Recipient 1'); + }).toPass(); + }); + + test('should autosave the signer deletion', async ({ page }) => { + const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page); + + await addSignerAndSave(page); + + await page.getByRole('button', { name: 'Add myself' }).click(); + await triggerAutosave(page); + + await page.getByTestId('remove-placeholder-recipient-button').first().click(); + await triggerAutosave(page); + + await expect(async () => { + const retrievedRecipients = await getRecipientsForTemplate({ + templateId: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedRecipients.length).toBe(1); + expect(retrievedRecipients[0].email).toBe(user.email); + expect(retrievedRecipients[0].name).toBe(user.name); + }).toPass(); + }); + + test('should autosave the signer update', async ({ page }) => { + const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page); + + await addSignerAndSave(page); + + await page.getByPlaceholder('Name').fill('Documenso Manager'); + await page.getByPlaceholder('Email').fill('manager@documenso.com'); + + await triggerAutosave(page); + + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Receives copy' }).click(); + + await triggerAutosave(page); + + await expect(async () => { + const retrievedRecipients = await getRecipientsForTemplate({ + templateId: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedRecipients.length).toBe(1); + expect(retrievedRecipients[0].email).toBe('manager@documenso.com'); + expect(retrievedRecipients[0].name).toBe('Documenso Manager'); + expect(retrievedRecipients[0].role).toBe('CC'); + }).toPass(); + }); + + test('should autosave the signing order change', async ({ page }) => { + const { user, template, team } = await setupTemplateAndNavigateToSignersStep(page); + + await addSignerAndSave(page); + + await page.getByRole('button', { name: 'Add placeholder recipient' }).click(); + + await page + .getByTestId('placeholder-recipient-email-input') + .nth(1) + .fill('recipient2@documenso.com'); + await page.getByTestId('placeholder-recipient-name-input').nth(1).fill('Recipient 2'); + + await page.getByRole('button', { name: 'Add placeholder recipient' }).click(); + + await page + .getByTestId('placeholder-recipient-email-input') + .nth(2) + .fill('recipient3@documenso.com'); + await page.getByTestId('placeholder-recipient-name-input').nth(2).fill('Recipient 3'); + + await triggerAutosave(page); + + await page.getByLabel('Enable signing order').check(); + await page.getByLabel('Allow signers to dictate next signer').check(); + await triggerAutosave(page); + + await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).fill('3'); + await page.getByTestId('placeholder-recipient-signing-order-input').nth(0).blur(); + await triggerAutosave(page); + + await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).fill('1'); + await page.getByTestId('placeholder-recipient-signing-order-input').nth(1).blur(); + await triggerAutosave(page); + + await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).fill('2'); + await page.getByTestId('placeholder-recipient-signing-order-input').nth(2).blur(); + await triggerAutosave(page); + + await expect(async () => { + const retrievedTemplate = await getTemplateById({ + id: template.id, + userId: user.id, + teamId: team.id, + }); + + const retrievedRecipients = await getRecipientsForTemplate({ + templateId: template.id, + userId: user.id, + teamId: team.id, + }); + + expect(retrievedTemplate.templateMeta?.signingOrder).toBe('SEQUENTIAL'); + expect(retrievedTemplate.templateMeta?.allowDictateNextSigner).toBe(true); + expect(retrievedRecipients.length).toBe(3); + expect(retrievedRecipients[0].signingOrder).toBe(2); + expect(retrievedRecipients[1].signingOrder).toBe(3); + expect(retrievedRecipients[2].signingOrder).toBe(1); + }).toPass(); + }); +}); diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index 5fb03ada5..3536e340d 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ testDir: './e2e', /* Run tests in files in parallel */ fullyParallel: false, - workers: 1, + workers: 4, maxFailures: process.env.CI ? 1 : undefined, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, diff --git a/packages/auth/server/lib/utils/handle-oauth-callback-url.ts b/packages/auth/server/lib/utils/handle-oauth-callback-url.ts index 76ec6a607..15fc1f7fa 100644 --- a/packages/auth/server/lib/utils/handle-oauth-callback-url.ts +++ b/packages/auth/server/lib/utils/handle-oauth-callback-url.ts @@ -92,7 +92,11 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti providerAccountId: sub, }, include: { - user: true, + user: { + select: { + id: true, + }, + }, }, }); @@ -107,6 +111,10 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti where: { email: email, }, + select: { + id: true, + emailVerified: true, + }, }); // Handle existing user but no account. diff --git a/packages/lib/client-only/hooks/use-autosave.ts b/packages/lib/client-only/hooks/use-autosave.ts new file mode 100644 index 000000000..5c9b3db62 --- /dev/null +++ b/packages/lib/client-only/hooks/use-autosave.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export const useAutoSave = (onSave: (data: T) => Promise) => { + const saveTimeoutRef = useRef(); + + const saveFormData = async (data: T) => { + try { + await onSave(data); + } catch (error) { + console.error('Auto-save failed:', error); + } + }; + + const scheduleSave = useCallback((data: T) => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + saveTimeoutRef.current = setTimeout(() => void saveFormData(data), 2000); + }, []); + + useEffect(() => { + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, []); + + return { scheduleSave }; +}; diff --git a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts index 5140c95ee..c17d7fc48 100644 --- a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts @@ -29,7 +29,13 @@ export const run = async ({ id: documentId, }, include: { - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, documentMeta: true, recipients: true, team: { diff --git a/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts b/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts index 444ce2985..db8ac2980 100644 --- a/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts @@ -39,7 +39,13 @@ export const run = async ({ }, }, include: { - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, }, }, }, @@ -51,7 +57,13 @@ export const run = async ({ organisationId: payload.organisationId, }, include: { - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, }, }); diff --git a/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts b/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts index 2bfc88aef..babfc7396 100644 --- a/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts @@ -39,7 +39,13 @@ export const run = async ({ }, }, include: { - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, }, }, }, @@ -49,6 +55,11 @@ export const run = async ({ where: { id: payload.memberUserId, }, + select: { + id: true, + email: true, + name: true, + }, }); const { branding, emailLanguage, senderEmail } = await getEmailContext({ diff --git a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts index 8912362d7..7f201f0ef 100644 --- a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts @@ -38,7 +38,13 @@ export const run = async ({ id: recipientId, }, }, - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, documentMeta: true, }, }); diff --git a/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts index d69304a12..f162ca134 100644 --- a/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-rejection-emails.handler.ts @@ -33,7 +33,13 @@ export const run = async ({ id: documentId, }, include: { - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, documentMeta: true, team: { select: { diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts index 7bdee4ab9..7d62ed1d2 100644 --- a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts @@ -42,6 +42,11 @@ export const run = async ({ where: { id: userId, }, + select: { + id: true, + email: true, + name: true, + }, }), prisma.document.findFirstOrThrow({ where: { diff --git a/packages/lib/server-only/admin/update-user.ts b/packages/lib/server-only/admin/update-user.ts index 6ee176803..cc9dcb80d 100644 --- a/packages/lib/server-only/admin/update-user.ts +++ b/packages/lib/server-only/admin/update-user.ts @@ -10,13 +10,7 @@ export type UpdateUserOptions = { }; export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) => { - await prisma.user.findFirstOrThrow({ - where: { - id, - }, - }); - - return await prisma.user.update({ + await prisma.user.update({ where: { id, }, diff --git a/packages/lib/server-only/document/create-document-v2.ts b/packages/lib/server-only/document/create-document-v2.ts index a381fd238..4fe6103d8 100644 --- a/packages/lib/server-only/document/create-document-v2.ts +++ b/packages/lib/server-only/document/create-document-v2.ts @@ -1,6 +1,7 @@ import type { DocumentVisibility, TemplateMeta } from '@prisma/client'; import { DocumentSource, + FolderType, RecipientRole, SendStatus, SigningStatus, @@ -14,7 +15,7 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques import { nanoid, prefixedId } from '@documenso/lib/universal/id'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; -import type { TCreateDocumentV2Request } from '@documenso/trpc/server/document-router/schema'; +import type { TCreateDocumentTemporaryRequest } from '@documenso/trpc/server/document-router/create-document-temporary.types'; import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; import type { TDocumentFormValues } from '../../types/document-form-values'; @@ -44,7 +45,8 @@ export type CreateDocumentOptions = { globalAccessAuth?: TDocumentAccessAuthTypes[]; globalActionAuth?: TDocumentActionAuthTypes[]; formValues?: TDocumentFormValues; - recipients: TCreateDocumentV2Request['recipients']; + recipients: TCreateDocumentTemporaryRequest['recipients']; + folderId?: string; }; meta?: Partial>; requestMetadata: ApiRequestMetadata; @@ -59,7 +61,7 @@ export const createDocumentV2 = async ({ meta, requestMetadata, }: CreateDocumentOptions) => { - const { title, formValues } = data; + const { title, formValues, folderId } = data; const team = await prisma.team.findFirst({ where: buildTeamWhereQuery({ teamId, userId }), @@ -78,6 +80,22 @@ export const createDocumentV2 = async ({ }); } + if (folderId) { + const folder = await prisma.folder.findUnique({ + where: { + id: folderId, + type: FolderType.DOCUMENT, + team: buildTeamWhereQuery({ teamId, userId }), + }, + }); + + if (!folder) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Folder not found', + }); + } + } + const settings = await getTeamSettings({ userId, teamId, @@ -164,6 +182,7 @@ export const createDocumentV2 = async ({ teamId, authOptions, visibility, + folderId, formValues, source: DocumentSource.DOCUMENT, documentMeta: { diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index e6b2bde42..97dd07582 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -49,6 +49,11 @@ export const findDocuments = async ({ where: { id: userId, }, + select: { + id: true, + email: true, + name: true, + }, }); let team = null; @@ -267,7 +272,7 @@ export const findDocuments = async ({ const findDocumentsFilter = ( status: ExtendedDocumentStatus, - user: User, + user: Pick, folderId?: string | null, ) => { return match(status) 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 b91427328..e062a7824 100644 --- a/packages/lib/server-only/document/get-document-by-token.ts +++ b/packages/lib/server-only/document/get-document-by-token.ts @@ -73,7 +73,13 @@ export const getDocumentAndSenderByToken = async ({ }, }, include: { - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, documentData: true, documentMeta: true, recipients: { @@ -90,9 +96,6 @@ export const getDocumentAndSenderByToken = async ({ }, }); - // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars - const { password: _password, ...user } = result.user; - const recipient = result.recipients[0]; // Sanity check, should not be possible. @@ -120,7 +123,11 @@ export const getDocumentAndSenderByToken = async ({ return { ...result, - user, + user: { + id: result.user.id, + email: result.user.email, + name: result.user.name, + }, }; }; diff --git a/packages/lib/server-only/document/get-document-with-details-by-id.ts b/packages/lib/server-only/document/get-document-with-details-by-id.ts index a889c410b..2a93156a7 100644 --- a/packages/lib/server-only/document/get-document-with-details-by-id.ts +++ b/packages/lib/server-only/document/get-document-with-details-by-id.ts @@ -7,14 +7,12 @@ export type GetDocumentWithDetailsByIdOptions = { documentId: number; userId: number; teamId: number; - folderId?: string; }; export const getDocumentWithDetailsById = async ({ documentId, userId, teamId, - folderId, }: GetDocumentWithDetailsByIdOptions) => { const { documentWhereInput } = await getDocumentWhereInput({ documentId, @@ -25,7 +23,6 @@ export const getDocumentWithDetailsById = async ({ const document = await prisma.document.findFirst({ where: { ...documentWhereInput, - folderId, }, include: { documentData: true, diff --git a/packages/lib/server-only/document/reject-document-with-token.ts b/packages/lib/server-only/document/reject-document-with-token.ts index f0c5764ef..2de1d0e81 100644 --- a/packages/lib/server-only/document/reject-document-with-token.ts +++ b/packages/lib/server-only/document/reject-document-with-token.ts @@ -28,13 +28,7 @@ export async function rejectDocumentWithToken({ documentId, }, include: { - document: { - include: { - user: true, - recipients: true, - documentMeta: true, - }, - }, + document: true, }, }); diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index b4392f891..b5e5ba256 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -33,7 +33,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo documentData: true, documentMeta: true, recipients: true, - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, team: { select: { id: true, diff --git a/packages/lib/server-only/document/send-delete-email.ts b/packages/lib/server-only/document/send-delete-email.ts index 8d786ff0b..e76ac636b 100644 --- a/packages/lib/server-only/document/send-delete-email.ts +++ b/packages/lib/server-only/document/send-delete-email.ts @@ -24,7 +24,13 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt id: documentId, }, include: { - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, documentMeta: true, }, }); diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index b8a77ee7e..22dbcb071 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -148,33 +148,6 @@ export const sendDocument = async ({ // throw new Error('Some signers have not been assigned a signature field.'); // } - const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings( - document.documentMeta, - ).recipientSigningRequest; - - // Only send email if one of the following is true: - // - It is explicitly set - // - The email is enabled for signing requests AND sendEmail is undefined - if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) { - await Promise.all( - recipientsToNotify.map(async (recipient) => { - if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) { - return; - } - - await jobs.triggerJob({ - name: 'send.signing.requested.email', - payload: { - userId, - documentId, - recipientId: recipient.id, - requestMetadata: requestMetadata?.requestMetadata, - }, - }); - }), - ); - } - const allRecipientsHaveNoActionToTake = document.recipients.every( (recipient) => recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED, @@ -227,6 +200,33 @@ export const sendDocument = async ({ }); }); + const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).recipientSigningRequest; + + // Only send email if one of the following is true: + // - It is explicitly set + // - The email is enabled for signing requests AND sendEmail is undefined + if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) { + await Promise.all( + recipientsToNotify.map(async (recipient) => { + if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) { + return; + } + + await jobs.triggerJob({ + name: 'send.signing.requested.email', + payload: { + userId, + documentId, + recipientId: recipient.id, + requestMetadata: requestMetadata?.requestMetadata, + }, + }); + }), + ); + } + await triggerWebhook({ event: WebhookTriggerEvents.DOCUMENT_SENT, data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)), diff --git a/packages/lib/server-only/document/super-delete-document.ts b/packages/lib/server-only/document/super-delete-document.ts index 87ab1a98f..ab6321c5e 100644 --- a/packages/lib/server-only/document/super-delete-document.ts +++ b/packages/lib/server-only/document/super-delete-document.ts @@ -30,7 +30,13 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo include: { recipients: true, documentMeta: true, - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, }, }); diff --git a/packages/lib/server-only/email/get-email-context.ts b/packages/lib/server-only/email/get-email-context.ts index 2dcf909fe..a9f924980 100644 --- a/packages/lib/server-only/email/get-email-context.ts +++ b/packages/lib/server-only/email/get-email-context.ts @@ -1,3 +1,5 @@ +import { P, match } from 'ts-pattern'; + import type { BrandingSettings } from '@documenso/email/providers/branding'; import { prisma } from '@documenso/prisma'; import type { @@ -104,7 +106,12 @@ export const getEmailContext = async ( } const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined; - const senderEmailId = meta?.emailId === null ? null : emailContext.settings.emailId; + + const senderEmailId = match(meta?.emailId) + .with(P.string, (emailId) => emailId) // Explicit string means to use the provided email ID. + .with(undefined, () => emailContext.settings.emailId) // Undefined means to use the inherited email ID. + .with(null, () => null) // Explicit null means to use the Documenso email. + .exhaustive(); const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId); diff --git a/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts b/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts index fe4853a5b..c0f2b123a 100644 --- a/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts +++ b/packages/lib/server-only/pdf/add-rejection-stamp-to-pdf.ts @@ -3,6 +3,7 @@ import type { PDFDocument } from 'pdf-lib'; import { TextAlignment, rgb, setFontAndSize } from 'pdf-lib'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { getPageSize } from './get-page-size'; /** * Adds a rejection stamp to each page of a PDF document. @@ -27,7 +28,7 @@ export async function addRejectionStampToPdf( for (let i = 0; i < pages.length; i++) { const page = pages[i]; - const { width, height } = page.getSize(); + const { width, height } = getPageSize(page); // Draw the "REJECTED" text const rejectedTitleText = 'DOCUMENT REJECTED'; diff --git a/packages/lib/server-only/pdf/get-page-size.ts b/packages/lib/server-only/pdf/get-page-size.ts new file mode 100644 index 000000000..76c5babf7 --- /dev/null +++ b/packages/lib/server-only/pdf/get-page-size.ts @@ -0,0 +1,18 @@ +import type { PDFPage } from 'pdf-lib'; + +/** + * Gets the effective page size for PDF operations. + * + * Uses CropBox by default to handle rare cases where MediaBox is larger than CropBox. + * Falls back to MediaBox when it's smaller than CropBox, following typical PDF reader behavior. + */ +export const getPageSize = (page: PDFPage) => { + const cropBox = page.getCropBox(); + const mediaBox = page.getMediaBox(); + + if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) { + return mediaBox; + } + + return cropBox; +}; diff --git a/packages/lib/server-only/pdf/insert-field-in-pdf.ts b/packages/lib/server-only/pdf/insert-field-in-pdf.ts index fe0a5749a..9b2b8183b 100644 --- a/packages/lib/server-only/pdf/insert-field-in-pdf.ts +++ b/packages/lib/server-only/pdf/insert-field-in-pdf.ts @@ -33,6 +33,7 @@ import { ZRadioFieldMeta, ZTextFieldMeta, } from '../../types/field-meta'; +import { getPageSize } from './get-page-size'; export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => { const [fontCaveat, fontNoto] = await Promise.all([ @@ -77,7 +78,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270; - let { width: pageWidth, height: pageHeight } = page.getSize(); + let { width: pageWidth, height: pageHeight } = getPageSize(page); // PDFs can have pages that are rotated, which are correctly rendered in the frontend. // However when we load the PDF in the backend, the rotation is applied. diff --git a/packages/lib/server-only/pdf/legacy-insert-field-in-pdf.ts b/packages/lib/server-only/pdf/legacy-insert-field-in-pdf.ts index ba351092c..86ceea65f 100644 --- a/packages/lib/server-only/pdf/legacy-insert-field-in-pdf.ts +++ b/packages/lib/server-only/pdf/legacy-insert-field-in-pdf.ts @@ -26,6 +26,7 @@ import { ZRadioFieldMeta, ZTextFieldMeta, } from '../../types/field-meta'; +import { getPageSize } from './get-page-size'; export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => { const [fontCaveat, fontNoto] = await Promise.all([ @@ -63,7 +64,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270; - let { width: pageWidth, height: pageHeight } = page.getSize(); + let { width: pageWidth, height: pageHeight } = getPageSize(page); // PDFs can have pages that are rotated, which are correctly rendered in the frontend. // However when we load the PDF in the backend, the rotation is applied. diff --git a/packages/lib/server-only/public-api/delete-api-token-by-id.ts b/packages/lib/server-only/public-api/delete-api-token-by-id.ts index 751b08c4f..b7723dfd7 100644 --- a/packages/lib/server-only/public-api/delete-api-token-by-id.ts +++ b/packages/lib/server-only/public-api/delete-api-token-by-id.ts @@ -25,7 +25,7 @@ export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOpt }); } - return await prisma.apiToken.delete({ + await prisma.apiToken.delete({ where: { id, teamId, diff --git a/packages/lib/server-only/recipient/get-recipients-for-template.ts b/packages/lib/server-only/recipient/get-recipients-for-template.ts index 14cafebfb..6d5c4c88f 100644 --- a/packages/lib/server-only/recipient/get-recipients-for-template.ts +++ b/packages/lib/server-only/recipient/get-recipients-for-template.ts @@ -1,5 +1,7 @@ import { prisma } from '@documenso/prisma'; +import { buildTeamWhereQuery } from '../../utils/teams'; + export interface GetRecipientsForTemplateOptions { templateId: number; userId: number; @@ -14,21 +16,12 @@ export const getRecipientsForTemplate = async ({ const recipients = await prisma.recipient.findMany({ where: { templateId, - template: teamId - ? { - team: { - id: teamId, - members: { - some: { - userId, - }, - }, - }, - } - : { - userId, - teamId: null, - }, + template: { + team: buildTeamWhereQuery({ + teamId, + userId, + }), + }, }, orderBy: { id: 'asc', 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 bed997aab..a7afd8a1a 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 @@ -105,7 +105,13 @@ export const createDocumentFromDirectTemplate = async ({ directLink: true, templateDocumentData: true, templateMeta: true, - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, }, }); 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 35946a155..b6ee8b222 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -2,6 +2,7 @@ import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/c import { DocumentSource, type Field, + FolderType, type Recipient, RecipientRole, SendStatus, @@ -69,6 +70,7 @@ export type CreateDocumentFromTemplateOptions = { email: string; signingOrder?: number | null; }[]; + folderId?: string; prefillFields?: TFieldMetaPrefillFieldsSchema[]; customDocumentDataId?: string; @@ -274,6 +276,7 @@ export const createDocumentFromTemplate = async ({ customDocumentDataId, override, requestMetadata, + folderId, prefillFields, }: CreateDocumentFromTemplateOptions) => { const template = await prisma.template.findUnique({ @@ -298,6 +301,22 @@ export const createDocumentFromTemplate = async ({ }); } + if (folderId) { + const folder = await prisma.folder.findUnique({ + where: { + id: folderId, + type: FolderType.DOCUMENT, + team: buildTeamWhereQuery({ teamId, userId }), + }, + }); + + if (!folder) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Folder not found', + }); + } + } + const settings = await getTeamSettings({ userId, teamId, @@ -368,6 +387,7 @@ export const createDocumentFromTemplate = async ({ externalId: externalId || template.externalId, templateId: template.id, userId, + folderId, teamId: template.teamId, title: override?.title || template.title, documentDataId: documentData.id, diff --git a/packages/lib/server-only/user/get-user-by-id.ts b/packages/lib/server-only/user/get-user-by-id.ts index a01447206..26e0fcc7b 100644 --- a/packages/lib/server-only/user/get-user-by-id.ts +++ b/packages/lib/server-only/user/get-user-by-id.ts @@ -1,13 +1,31 @@ import { prisma } from '@documenso/prisma'; +import { AppError, AppErrorCode } from '../../errors/app-error'; + export interface GetUserByIdOptions { id: number; } export const getUserById = async ({ id }: GetUserByIdOptions) => { - return await prisma.user.findFirstOrThrow({ + const user = await prisma.user.findFirst({ where: { id, }, + select: { + id: true, + name: true, + email: true, + emailVerified: true, + roles: true, + disabled: true, + twoFactorEnabled: true, + signature: true, + }, }); + + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + + return user; }; diff --git a/packages/lib/server-only/user/reset-password.ts b/packages/lib/server-only/user/reset-password.ts index e01555cce..99d796e7b 100644 --- a/packages/lib/server-only/user/reset-password.ts +++ b/packages/lib/server-only/user/reset-password.ts @@ -24,7 +24,14 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP token, }, include: { - user: true, + user: { + select: { + id: true, + email: true, + name: true, + password: true, + }, + }, }, }); diff --git a/packages/lib/server-only/user/update-profile.ts b/packages/lib/server-only/user/update-profile.ts index b156a06af..766a09e3e 100644 --- a/packages/lib/server-only/user/update-profile.ts +++ b/packages/lib/server-only/user/update-profile.ts @@ -24,7 +24,7 @@ export const updateProfile = async ({ }, }); - return await prisma.$transaction(async (tx) => { + await prisma.$transaction(async (tx) => { await tx.userSecurityAuditLog.create({ data: { userId, @@ -34,7 +34,7 @@ export const updateProfile = async ({ }, }); - return await tx.user.update({ + await tx.user.update({ where: { id: userId, }, diff --git a/packages/lib/server-only/user/verify-email.ts b/packages/lib/server-only/user/verify-email.ts index 5285b9476..1b3a44e71 100644 --- a/packages/lib/server-only/user/verify-email.ts +++ b/packages/lib/server-only/user/verify-email.ts @@ -12,7 +12,13 @@ export type VerifyEmailProps = { export const verifyEmail = async ({ token }: VerifyEmailProps) => { const verificationToken = await prisma.verificationToken.findFirst({ include: { - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, }, where: { token, diff --git a/packages/lib/utils/mask-recipient-tokens-for-document.ts b/packages/lib/utils/mask-recipient-tokens-for-document.ts index c0eff4588..ddadb4f3f 100644 --- a/packages/lib/utils/mask-recipient-tokens-for-document.ts +++ b/packages/lib/utils/mask-recipient-tokens-for-document.ts @@ -4,7 +4,7 @@ import type { DocumentWithRecipients } from '@documenso/prisma/types/document-wi export type MaskRecipientTokensForDocumentOptions = { document: T; - user?: User; + user?: Pick; token?: string; }; diff --git a/packages/trpc/server/admin-router/delete-document.ts b/packages/trpc/server/admin-router/delete-document.ts new file mode 100644 index 000000000..70fc96591 --- /dev/null +++ b/packages/trpc/server/admin-router/delete-document.ts @@ -0,0 +1,28 @@ +import { sendDeleteEmail } from '@documenso/lib/server-only/document/send-delete-email'; +import { superDeleteDocument } from '@documenso/lib/server-only/document/super-delete-document'; + +import { adminProcedure } from '../trpc'; +import { + ZDeleteDocumentRequestSchema, + ZDeleteDocumentResponseSchema, +} from './delete-document.types'; + +export const deleteDocumentRoute = adminProcedure + .input(ZDeleteDocumentRequestSchema) + .output(ZDeleteDocumentResponseSchema) + .mutation(async ({ ctx, input }) => { + const { id, reason } = input; + + ctx.logger.info({ + input: { + id, + }, + }); + + await sendDeleteEmail({ documentId: id, reason }); + + await superDeleteDocument({ + id, + requestMetadata: ctx.metadata.requestMetadata, + }); + }); diff --git a/packages/trpc/server/admin-router/delete-document.types.ts b/packages/trpc/server/admin-router/delete-document.types.ts new file mode 100644 index 000000000..58ff6ff35 --- /dev/null +++ b/packages/trpc/server/admin-router/delete-document.types.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const ZDeleteDocumentRequestSchema = z.object({ + id: z.number().min(1), + reason: z.string(), +}); + +export const ZDeleteDocumentResponseSchema = z.void(); + +export type TDeleteDocumentRequest = z.infer; +export type TDeleteDocumentResponse = z.infer; diff --git a/packages/trpc/server/admin-router/delete-user.ts b/packages/trpc/server/admin-router/delete-user.ts new file mode 100644 index 000000000..c78fdd651 --- /dev/null +++ b/packages/trpc/server/admin-router/delete-user.ts @@ -0,0 +1,19 @@ +import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; + +import { adminProcedure } from '../trpc'; +import { ZDeleteUserRequestSchema, ZDeleteUserResponseSchema } from './delete-user.types'; + +export const deleteUserRoute = adminProcedure + .input(ZDeleteUserRequestSchema) + .output(ZDeleteUserResponseSchema) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + ctx.logger.info({ + input: { + id, + }, + }); + + await deleteUser({ id }); + }); diff --git a/packages/trpc/server/admin-router/delete-user.types.ts b/packages/trpc/server/admin-router/delete-user.types.ts new file mode 100644 index 000000000..b2d01f91b --- /dev/null +++ b/packages/trpc/server/admin-router/delete-user.types.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const ZDeleteUserRequestSchema = z.object({ + id: z.number().min(1), +}); + +export const ZDeleteUserResponseSchema = z.void(); + +export type TDeleteUserRequest = z.infer; +export type TDeleteUserResponse = z.infer; diff --git a/packages/trpc/server/admin-router/disable-user.ts b/packages/trpc/server/admin-router/disable-user.ts new file mode 100644 index 000000000..9a2a1a854 --- /dev/null +++ b/packages/trpc/server/admin-router/disable-user.ts @@ -0,0 +1,29 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { disableUser } from '@documenso/lib/server-only/user/disable-user'; +import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; + +import { adminProcedure } from '../trpc'; +import { ZDisableUserRequestSchema, ZDisableUserResponseSchema } from './disable-user.types'; + +export const disableUserRoute = adminProcedure + .input(ZDisableUserRequestSchema) + .output(ZDisableUserResponseSchema) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + ctx.logger.info({ + input: { + id, + }, + }); + + const user = await getUserById({ id }).catch(() => null); + + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'User not found', + }); + } + + await disableUser({ id }); + }); diff --git a/packages/trpc/server/admin-router/disable-user.types.ts b/packages/trpc/server/admin-router/disable-user.types.ts new file mode 100644 index 000000000..51d26a3d8 --- /dev/null +++ b/packages/trpc/server/admin-router/disable-user.types.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const ZDisableUserRequestSchema = z.object({ + id: z.number().min(1), +}); + +export const ZDisableUserResponseSchema = z.void(); + +export type TDisableUserRequest = z.infer; +export type TDisableUserResponse = z.infer; diff --git a/packages/trpc/server/admin-router/enable-user.ts b/packages/trpc/server/admin-router/enable-user.ts new file mode 100644 index 000000000..171e3bf8a --- /dev/null +++ b/packages/trpc/server/admin-router/enable-user.ts @@ -0,0 +1,29 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { enableUser } from '@documenso/lib/server-only/user/enable-user'; +import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; + +import { adminProcedure } from '../trpc'; +import { ZEnableUserRequestSchema, ZEnableUserResponseSchema } from './enable-user.types'; + +export const enableUserRoute = adminProcedure + .input(ZEnableUserRequestSchema) + .output(ZEnableUserResponseSchema) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + ctx.logger.info({ + input: { + id, + }, + }); + + const user = await getUserById({ id }).catch(() => null); + + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'User not found', + }); + } + + await enableUser({ id }); + }); diff --git a/packages/trpc/server/admin-router/enable-user.types.ts b/packages/trpc/server/admin-router/enable-user.types.ts new file mode 100644 index 000000000..5e44cb18e --- /dev/null +++ b/packages/trpc/server/admin-router/enable-user.types.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const ZEnableUserRequestSchema = z.object({ + id: z.number().min(1), +}); + +export const ZEnableUserResponseSchema = z.void(); + +export type TEnableUserRequest = z.infer; +export type TEnableUserResponse = z.infer; diff --git a/packages/trpc/server/admin-router/find-documents.ts b/packages/trpc/server/admin-router/find-documents.ts new file mode 100644 index 000000000..7ce96c1f5 --- /dev/null +++ b/packages/trpc/server/admin-router/find-documents.ts @@ -0,0 +1,13 @@ +import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; + +import { adminProcedure } from '../trpc'; +import { ZFindDocumentsRequestSchema, ZFindDocumentsResponseSchema } from './find-documents.types'; + +export const findDocumentsRoute = adminProcedure + .input(ZFindDocumentsRequestSchema) + .output(ZFindDocumentsResponseSchema) + .query(async ({ input }) => { + const { query, page, perPage } = input; + + return await findDocuments({ query, page, perPage }); + }); diff --git a/packages/trpc/server/admin-router/find-documents.types.ts b/packages/trpc/server/admin-router/find-documents.types.ts new file mode 100644 index 000000000..b6fc4e864 --- /dev/null +++ b/packages/trpc/server/admin-router/find-documents.types.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import { ZDocumentManySchema } from '@documenso/lib/types/document'; +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; + +export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({ + perPage: z.number().optional().default(20), +}); + +export const ZFindDocumentsResponseSchema = ZFindResultResponse.extend({ + data: ZDocumentManySchema.omit({ + team: true, + }).array(), +}); + +export type TFindDocumentsRequest = z.infer; +export type TFindDocumentsResponse = z.infer; diff --git a/packages/trpc/server/admin-router/get-user.ts b/packages/trpc/server/admin-router/get-user.ts new file mode 100644 index 000000000..c60fd6b21 --- /dev/null +++ b/packages/trpc/server/admin-router/get-user.ts @@ -0,0 +1,19 @@ +import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; + +import { adminProcedure } from '../trpc'; +import { ZGetUserRequestSchema, ZGetUserResponseSchema } from './get-user.types'; + +export const getUserRoute = adminProcedure + .input(ZGetUserRequestSchema) + .output(ZGetUserResponseSchema) + .query(async ({ input, ctx }) => { + const { id } = input; + + ctx.logger.info({ + input: { + id, + }, + }); + + return await getUserById({ id }); + }); diff --git a/packages/trpc/server/admin-router/get-user.types.ts b/packages/trpc/server/admin-router/get-user.types.ts new file mode 100644 index 000000000..fe4ebac5e --- /dev/null +++ b/packages/trpc/server/admin-router/get-user.types.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema'; + +export const ZGetUserRequestSchema = z.object({ + id: z.number().min(1), +}); + +export const ZGetUserResponseSchema = UserSchema.pick({ + id: true, + name: true, + email: true, + emailVerified: true, + roles: true, + disabled: true, + twoFactorEnabled: true, + signature: true, +}); + +export type TGetUserRequest = z.infer; +export type TGetUserResponse = z.infer; diff --git a/packages/trpc/server/admin-router/reseal-document.ts b/packages/trpc/server/admin-router/reseal-document.ts new file mode 100644 index 000000000..7436d29c7 --- /dev/null +++ b/packages/trpc/server/admin-router/reseal-document.ts @@ -0,0 +1,28 @@ +import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; +import { sealDocument } from '@documenso/lib/server-only/document/seal-document'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; + +import { adminProcedure } from '../trpc'; +import { + ZResealDocumentRequestSchema, + ZResealDocumentResponseSchema, +} from './reseal-document.types'; + +export const resealDocumentRoute = adminProcedure + .input(ZResealDocumentRequestSchema) + .output(ZResealDocumentResponseSchema) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + ctx.logger.info({ + input: { + id, + }, + }); + + const document = await getEntireDocument({ id }); + + const isResealing = isDocumentCompleted(document.status); + + await sealDocument({ documentId: id, isResealing }); + }); diff --git a/packages/trpc/server/admin-router/reseal-document.types.ts b/packages/trpc/server/admin-router/reseal-document.types.ts new file mode 100644 index 000000000..e33c2dc5c --- /dev/null +++ b/packages/trpc/server/admin-router/reseal-document.types.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const ZResealDocumentRequestSchema = z.object({ + id: z.number().min(1), +}); + +export const ZResealDocumentResponseSchema = z.void(); + +export type TResealDocumentRequest = z.infer; +export type TResealDocumentResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 3487a3d21..f8d472a79 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -1,40 +1,24 @@ -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; -import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document'; -import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient'; -import { updateUser } from '@documenso/lib/server-only/admin/update-user'; -import { sealDocument } from '@documenso/lib/server-only/document/seal-document'; -import { sendDeleteEmail } from '@documenso/lib/server-only/document/send-delete-email'; -import { superDeleteDocument } from '@documenso/lib/server-only/document/super-delete-document'; -import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting'; -import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; -import { disableUser } from '@documenso/lib/server-only/user/disable-user'; -import { enableUser } from '@documenso/lib/server-only/user/enable-user'; -import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; -import { isDocumentCompleted } from '@documenso/lib/utils/document'; - -import { adminProcedure, router } from '../trpc'; +import { router } from '../trpc'; import { createAdminOrganisationRoute } from './create-admin-organisation'; import { createStripeCustomerRoute } from './create-stripe-customer'; import { createSubscriptionClaimRoute } from './create-subscription-claim'; +import { deleteDocumentRoute } from './delete-document'; import { deleteSubscriptionClaimRoute } from './delete-subscription-claim'; +import { deleteUserRoute } from './delete-user'; +import { disableUserRoute } from './disable-user'; +import { enableUserRoute } from './enable-user'; import { findAdminOrganisationsRoute } from './find-admin-organisations'; +import { findDocumentsRoute } from './find-documents'; import { findSubscriptionClaimsRoute } from './find-subscription-claims'; import { getAdminOrganisationRoute } from './get-admin-organisation'; +import { getUserRoute } from './get-user'; +import { resealDocumentRoute } from './reseal-document'; import { resetTwoFactorRoute } from './reset-two-factor-authentication'; -import { - ZAdminDeleteDocumentMutationSchema, - ZAdminDeleteUserMutationSchema, - ZAdminDisableUserMutationSchema, - ZAdminEnableUserMutationSchema, - ZAdminFindDocumentsQuerySchema, - ZAdminResealDocumentMutationSchema, - ZAdminUpdateProfileMutationSchema, - ZAdminUpdateRecipientMutationSchema, - ZAdminUpdateSiteSettingMutationSchema, -} from './schema'; import { updateAdminOrganisationRoute } from './update-admin-organisation'; +import { updateRecipientRoute } from './update-recipient'; +import { updateSiteSettingRoute } from './update-site-setting'; import { updateSubscriptionClaimRoute } from './update-subscription-claim'; +import { updateUserRoute } from './update-user'; export const adminRouter = router({ organisation: { @@ -53,156 +37,20 @@ export const adminRouter = router({ createCustomer: createStripeCustomerRoute, }, user: { + get: getUserRoute, + update: updateUserRoute, + delete: deleteUserRoute, + enable: enableUserRoute, + disable: disableUserRoute, resetTwoFactor: resetTwoFactorRoute, }, - - // Todo: migrate old routes - findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => { - const { query, page, perPage } = input; - - return await findDocuments({ query, page, perPage }); - }), - - updateUser: adminProcedure - .input(ZAdminUpdateProfileMutationSchema) - .mutation(async ({ input, ctx }) => { - const { id, name, email, roles } = input; - - ctx.logger.info({ - input: { - id, - roles, - }, - }); - - return await updateUser({ id, name, email, roles }); - }), - - updateRecipient: adminProcedure - .input(ZAdminUpdateRecipientMutationSchema) - .mutation(async ({ input, ctx }) => { - const { id, name, email } = input; - - ctx.logger.info({ - input: { - id, - }, - }); - - return await updateRecipient({ id, name, email }); - }), - - updateSiteSetting: adminProcedure - .input(ZAdminUpdateSiteSettingMutationSchema) - .mutation(async ({ ctx, input }) => { - const { id, enabled, data } = input; - - ctx.logger.info({ - input: { - id, - }, - }); - - return await upsertSiteSetting({ - id, - enabled, - data, - userId: ctx.user.id, - }); - }), - - resealDocument: adminProcedure - .input(ZAdminResealDocumentMutationSchema) - .mutation(async ({ input, ctx }) => { - const { id } = input; - - ctx.logger.info({ - input: { - id, - }, - }); - - const document = await getEntireDocument({ id }); - - const isResealing = isDocumentCompleted(document.status); - - return await sealDocument({ documentId: id, isResealing }); - }), - - enableUser: adminProcedure - .input(ZAdminEnableUserMutationSchema) - .mutation(async ({ input, ctx }) => { - const { id } = input; - - ctx.logger.info({ - input: { - id, - }, - }); - - const user = await getUserById({ id }).catch(() => null); - - if (!user) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'User not found', - }); - } - - return await enableUser({ id }); - }), - - disableUser: adminProcedure - .input(ZAdminDisableUserMutationSchema) - .mutation(async ({ input, ctx }) => { - const { id } = input; - - ctx.logger.info({ - input: { - id, - }, - }); - - const user = await getUserById({ id }).catch(() => null); - - if (!user) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'User not found', - }); - } - - return await disableUser({ id }); - }), - - deleteUser: adminProcedure - .input(ZAdminDeleteUserMutationSchema) - .mutation(async ({ input, ctx }) => { - const { id } = input; - - ctx.logger.info({ - input: { - id, - }, - }); - - return await deleteUser({ id }); - }), - - deleteDocument: adminProcedure - .input(ZAdminDeleteDocumentMutationSchema) - .mutation(async ({ ctx, input }) => { - const { id, reason } = input; - - ctx.logger.info({ - input: { - id, - }, - }); - - await sendDeleteEmail({ documentId: id, reason }); - - return await superDeleteDocument({ - id, - requestMetadata: ctx.metadata.requestMetadata, - }); - }), + document: { + find: findDocumentsRoute, + delete: deleteDocumentRoute, + reseal: resealDocumentRoute, + }, + recipient: { + update: updateRecipientRoute, + }, + updateSiteSetting: updateSiteSettingRoute, }); diff --git a/packages/trpc/server/admin-router/schema.ts b/packages/trpc/server/admin-router/schema.ts deleted file mode 100644 index 6fc7f5df5..000000000 --- a/packages/trpc/server/admin-router/schema.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Role } from '@prisma/client'; -import z from 'zod'; - -import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema'; -import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; - -export const ZAdminFindDocumentsQuerySchema = ZFindSearchParamsSchema.extend({ - perPage: z.number().optional().default(20), -}); - -export type TAdminFindDocumentsQuerySchema = z.infer; - -export const ZAdminUpdateProfileMutationSchema = z.object({ - id: z.number().min(1), - name: z.string().nullish(), - email: z.string().email().optional(), - roles: z.array(z.nativeEnum(Role)).optional(), -}); - -export type TAdminUpdateProfileMutationSchema = z.infer; - -export const ZAdminUpdateRecipientMutationSchema = z.object({ - id: z.number().min(1), - name: z.string().optional(), - email: z.string().email().optional(), -}); - -export type TAdminUpdateRecipientMutationSchema = z.infer< - typeof ZAdminUpdateRecipientMutationSchema ->; - -export const ZAdminUpdateSiteSettingMutationSchema = ZSiteSettingSchema; - -export type TAdminUpdateSiteSettingMutationSchema = z.infer< - typeof ZAdminUpdateSiteSettingMutationSchema ->; - -export const ZAdminResealDocumentMutationSchema = z.object({ - id: z.number().min(1), -}); - -export type TAdminResealDocumentMutationSchema = z.infer; - -export const ZAdminDeleteUserMutationSchema = z.object({ - id: z.number().min(1), -}); - -export type TAdminDeleteUserMutationSchema = z.infer; - -export const ZAdminEnableUserMutationSchema = z.object({ - id: z.number().min(1), -}); - -export type TAdminEnableUserMutationSchema = z.infer; - -export const ZAdminDisableUserMutationSchema = z.object({ - id: z.number().min(1), -}); - -export type TAdminDisableUserMutationSchema = z.infer; - -export const ZAdminDeleteDocumentMutationSchema = z.object({ - id: z.number().min(1), - reason: z.string(), -}); - -export type TAdminDeleteDocomentMutationSchema = z.infer; diff --git a/packages/trpc/server/admin-router/update-recipient.ts b/packages/trpc/server/admin-router/update-recipient.ts new file mode 100644 index 000000000..1fc286af2 --- /dev/null +++ b/packages/trpc/server/admin-router/update-recipient.ts @@ -0,0 +1,22 @@ +import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient'; + +import { adminProcedure } from '../trpc'; +import { + ZUpdateRecipientRequestSchema, + ZUpdateRecipientResponseSchema, +} from './update-recipient.types'; + +export const updateRecipientRoute = adminProcedure + .input(ZUpdateRecipientRequestSchema) + .output(ZUpdateRecipientResponseSchema) + .mutation(async ({ input, ctx }) => { + const { id, name, email } = input; + + ctx.logger.info({ + input: { + id, + }, + }); + + await updateRecipient({ id, name, email }); + }); diff --git a/packages/trpc/server/admin-router/update-recipient.types.ts b/packages/trpc/server/admin-router/update-recipient.types.ts new file mode 100644 index 000000000..7b9bc4008 --- /dev/null +++ b/packages/trpc/server/admin-router/update-recipient.types.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const ZUpdateRecipientRequestSchema = z.object({ + id: z.number().min(1), + name: z.string().optional(), + email: z.string().email().optional(), +}); + +export const ZUpdateRecipientResponseSchema = z.void(); + +export type TUpdateRecipientRequest = z.infer; +export type TUpdateRecipientResponse = z.infer; diff --git a/packages/trpc/server/admin-router/update-site-setting.ts b/packages/trpc/server/admin-router/update-site-setting.ts new file mode 100644 index 000000000..1b0ff971a --- /dev/null +++ b/packages/trpc/server/admin-router/update-site-setting.ts @@ -0,0 +1,27 @@ +import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting'; + +import { adminProcedure } from '../trpc'; +import { + ZUpdateSiteSettingRequestSchema, + ZUpdateSiteSettingResponseSchema, +} from './update-site-setting.types'; + +export const updateSiteSettingRoute = adminProcedure + .input(ZUpdateSiteSettingRequestSchema) + .output(ZUpdateSiteSettingResponseSchema) + .mutation(async ({ ctx, input }) => { + const { id, enabled, data } = input; + + ctx.logger.info({ + input: { + id, + }, + }); + + await upsertSiteSetting({ + id, + enabled, + data, + userId: ctx.user.id, + }); + }); diff --git a/packages/trpc/server/admin-router/update-site-setting.types.ts b/packages/trpc/server/admin-router/update-site-setting.types.ts new file mode 100644 index 000000000..bd8638b3e --- /dev/null +++ b/packages/trpc/server/admin-router/update-site-setting.types.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema'; + +export const ZUpdateSiteSettingRequestSchema = ZSiteSettingSchema; + +export const ZUpdateSiteSettingResponseSchema = z.void(); + +export type TUpdateSiteSettingRequest = z.infer; +export type TUpdateSiteSettingResponse = z.infer; diff --git a/packages/trpc/server/admin-router/update-user.ts b/packages/trpc/server/admin-router/update-user.ts new file mode 100644 index 000000000..d04a7f80e --- /dev/null +++ b/packages/trpc/server/admin-router/update-user.ts @@ -0,0 +1,20 @@ +import { updateUser } from '@documenso/lib/server-only/admin/update-user'; + +import { adminProcedure } from '../trpc'; +import { ZUpdateUserRequestSchema, ZUpdateUserResponseSchema } from './update-user.types'; + +export const updateUserRoute = adminProcedure + .input(ZUpdateUserRequestSchema) + .output(ZUpdateUserResponseSchema) + .mutation(async ({ input, ctx }) => { + const { id, name, email, roles } = input; + + ctx.logger.info({ + input: { + id, + roles, + }, + }); + + await updateUser({ id, name, email, roles }); + }); diff --git a/packages/trpc/server/admin-router/update-user.types.ts b/packages/trpc/server/admin-router/update-user.types.ts new file mode 100644 index 000000000..f4650fca4 --- /dev/null +++ b/packages/trpc/server/admin-router/update-user.types.ts @@ -0,0 +1,14 @@ +import { Role } from '@prisma/client'; +import { z } from 'zod'; + +export const ZUpdateUserRequestSchema = z.object({ + id: z.number().min(1), + name: z.string().nullish(), + email: z.string().email().optional(), + roles: z.array(z.nativeEnum(Role)).optional(), +}); + +export const ZUpdateUserResponseSchema = z.void(); + +export type TUpdateUserRequest = z.infer; +export type TUpdateUserResponse = z.infer; diff --git a/packages/trpc/server/api-token-router/create-api-token.ts b/packages/trpc/server/api-token-router/create-api-token.ts new file mode 100644 index 000000000..6b8e665f8 --- /dev/null +++ b/packages/trpc/server/api-token-router/create-api-token.ts @@ -0,0 +1,27 @@ +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZCreateApiTokenRequestSchema, + ZCreateApiTokenResponseSchema, +} from './create-api-token.types'; + +export const createApiTokenRoute = authenticatedProcedure + .input(ZCreateApiTokenRequestSchema) + .output(ZCreateApiTokenResponseSchema) + .mutation(async ({ input, ctx }) => { + const { tokenName, teamId, expirationDate } = input; + + ctx.logger.info({ + input: { + teamId, + }, + }); + + return await createApiToken({ + userId: ctx.user.id, + teamId, + tokenName, + expiresIn: expirationDate, + }); + }); diff --git a/packages/trpc/server/api-token-router/create-api-token.types.ts b/packages/trpc/server/api-token-router/create-api-token.types.ts new file mode 100644 index 000000000..c73c65833 --- /dev/null +++ b/packages/trpc/server/api-token-router/create-api-token.types.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const ZCreateApiTokenRequestSchema = z.object({ + teamId: z.number(), + tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }), + expirationDate: z.string().nullable(), +}); + +export const ZCreateApiTokenResponseSchema = z.object({ + id: z.number(), + token: z.string(), +}); diff --git a/packages/trpc/server/api-token-router/delete-api-token.ts b/packages/trpc/server/api-token-router/delete-api-token.ts new file mode 100644 index 000000000..45fcc2dee --- /dev/null +++ b/packages/trpc/server/api-token-router/delete-api-token.ts @@ -0,0 +1,27 @@ +import { deleteTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZDeleteApiTokenRequestSchema, + ZDeleteApiTokenResponseSchema, +} from './delete-api-token.types'; + +export const deleteApiTokenRoute = authenticatedProcedure + .input(ZDeleteApiTokenRequestSchema) + .output(ZDeleteApiTokenResponseSchema) + .mutation(async ({ input, ctx }) => { + const { id, teamId } = input; + + ctx.logger.info({ + input: { + id, + teamId, + }, + }); + + await deleteTokenById({ + id, + teamId, + userId: ctx.user.id, + }); + }); diff --git a/packages/trpc/server/api-token-router/delete-api-token.types.ts b/packages/trpc/server/api-token-router/delete-api-token.types.ts new file mode 100644 index 000000000..7cf235fae --- /dev/null +++ b/packages/trpc/server/api-token-router/delete-api-token.types.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const ZDeleteApiTokenRequestSchema = z.object({ + id: z.number().min(1), + teamId: z.number(), +}); + +export const ZDeleteApiTokenResponseSchema = z.void(); diff --git a/packages/trpc/server/api-token-router/get-api-tokens.ts b/packages/trpc/server/api-token-router/get-api-tokens.ts new file mode 100644 index 000000000..4473c8d42 --- /dev/null +++ b/packages/trpc/server/api-token-router/get-api-tokens.ts @@ -0,0 +1,19 @@ +import { getApiTokens } from '@documenso/lib/server-only/public-api/get-api-tokens'; + +import { authenticatedProcedure } from '../trpc'; +import { ZGetApiTokensRequestSchema, ZGetApiTokensResponseSchema } from './get-api-tokens.types'; + +export const getApiTokensRoute = authenticatedProcedure + .input(ZGetApiTokensRequestSchema) + .output(ZGetApiTokensResponseSchema) + .query(async ({ ctx }) => { + const { teamId } = ctx; + + ctx.logger.info({ + input: { + teamId, + }, + }); + + return await getApiTokens({ userId: ctx.user.id, teamId }); + }); diff --git a/packages/trpc/server/api-token-router/get-api-tokens.types.ts b/packages/trpc/server/api-token-router/get-api-tokens.types.ts new file mode 100644 index 000000000..9e380bf9d --- /dev/null +++ b/packages/trpc/server/api-token-router/get-api-tokens.types.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +import ApiTokenSchema from '@documenso/prisma/generated/zod/modelSchema/ApiTokenSchema'; + +export const ZGetApiTokensRequestSchema = z.void(); + +export const ZGetApiTokensResponseSchema = z.array( + ApiTokenSchema.pick({ + id: true, + name: true, + createdAt: true, + expires: true, + }), +); + +export type TGetApiTokensResponse = z.infer; diff --git a/packages/trpc/server/api-token-router/router.ts b/packages/trpc/server/api-token-router/router.ts index f1439060c..8a17d5b66 100644 --- a/packages/trpc/server/api-token-router/router.ts +++ b/packages/trpc/server/api-token-router/router.ts @@ -1,50 +1,10 @@ -import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; -import { deleteTokenById } from '@documenso/lib/server-only/public-api/delete-api-token-by-id'; -import { getApiTokens } from '@documenso/lib/server-only/public-api/get-api-tokens'; - -import { authenticatedProcedure, router } from '../trpc'; -import { ZCreateTokenMutationSchema, ZDeleteTokenByIdMutationSchema } from './schema'; +import { router } from '../trpc'; +import { createApiTokenRoute } from './create-api-token'; +import { deleteApiTokenRoute } from './delete-api-token'; +import { getApiTokensRoute } from './get-api-tokens'; export const apiTokenRouter = router({ - getTokens: authenticatedProcedure.query(async ({ ctx }) => { - return await getApiTokens({ userId: ctx.user.id, teamId: ctx.teamId }); - }), - - createToken: authenticatedProcedure - .input(ZCreateTokenMutationSchema) - .mutation(async ({ input, ctx }) => { - const { tokenName, teamId, expirationDate } = input; - - ctx.logger.info({ - input: { - teamId, - }, - }); - - return await createApiToken({ - userId: ctx.user.id, - teamId, - tokenName, - expiresIn: expirationDate, - }); - }), - - deleteTokenById: authenticatedProcedure - .input(ZDeleteTokenByIdMutationSchema) - .mutation(async ({ input, ctx }) => { - const { id, teamId } = input; - - ctx.logger.info({ - input: { - id, - teamId, - }, - }); - - return await deleteTokenById({ - id, - teamId, - userId: ctx.user.id, - }); - }), + create: createApiTokenRoute, + getMany: getApiTokensRoute, + delete: deleteApiTokenRoute, }); diff --git a/packages/trpc/server/api-token-router/schema.ts b/packages/trpc/server/api-token-router/schema.ts deleted file mode 100644 index 85c41d956..000000000 --- a/packages/trpc/server/api-token-router/schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from 'zod'; - -export const ZCreateTokenMutationSchema = z.object({ - teamId: z.number(), - tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }), - expirationDate: z.string().nullable(), -}); - -export type TCreateTokenMutationSchema = z.infer; - -export const ZDeleteTokenByIdMutationSchema = z.object({ - id: z.number().min(1), - teamId: z.number(), -}); - -export type TDeleteTokenByIdMutationSchema = z.infer; diff --git a/packages/trpc/server/auth-router/create-passkey-authentication-options.ts b/packages/trpc/server/auth-router/create-passkey-authentication-options.ts new file mode 100644 index 000000000..6b507f7ca --- /dev/null +++ b/packages/trpc/server/auth-router/create-passkey-authentication-options.ts @@ -0,0 +1,17 @@ +import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZCreatePasskeyAuthenticationOptionsRequestSchema, + ZCreatePasskeyAuthenticationOptionsResponseSchema, +} from './create-passkey-authentication-options.types'; + +export const createPasskeyAuthenticationOptionsRoute = authenticatedProcedure + .input(ZCreatePasskeyAuthenticationOptionsRequestSchema) + .output(ZCreatePasskeyAuthenticationOptionsResponseSchema) + .mutation(async ({ ctx, input }) => { + return await createPasskeyAuthenticationOptions({ + userId: ctx.user.id, + preferredPasskeyId: input?.preferredPasskeyId, + }); + }); diff --git a/packages/trpc/server/auth-router/create-passkey-authentication-options.types.ts b/packages/trpc/server/auth-router/create-passkey-authentication-options.types.ts new file mode 100644 index 000000000..a9ae6ad12 --- /dev/null +++ b/packages/trpc/server/auth-router/create-passkey-authentication-options.types.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const ZCreatePasskeyAuthenticationOptionsRequestSchema = z + .object({ + preferredPasskeyId: z.string().optional(), + }) + .optional(); + +export const ZCreatePasskeyAuthenticationOptionsResponseSchema = z.object({ + tokenReference: z.string(), + options: z.any(), // PublicKeyCredentialRequestOptions type +}); + +export type TCreatePasskeyAuthenticationOptionsRequest = z.infer< + typeof ZCreatePasskeyAuthenticationOptionsRequestSchema +>; +export type TCreatePasskeyAuthenticationOptionsResponse = z.infer< + typeof ZCreatePasskeyAuthenticationOptionsResponseSchema +>; diff --git a/packages/trpc/server/auth-router/create-passkey-registration-options.ts b/packages/trpc/server/auth-router/create-passkey-registration-options.ts new file mode 100644 index 000000000..969da6d98 --- /dev/null +++ b/packages/trpc/server/auth-router/create-passkey-registration-options.ts @@ -0,0 +1,16 @@ +import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZCreatePasskeyRegistrationOptionsRequestSchema, + ZCreatePasskeyRegistrationOptionsResponseSchema, +} from './create-passkey-registration-options.types'; + +export const createPasskeyRegistrationOptionsRoute = authenticatedProcedure + .input(ZCreatePasskeyRegistrationOptionsRequestSchema) + .output(ZCreatePasskeyRegistrationOptionsResponseSchema) + .mutation(async ({ ctx }) => { + return await createPasskeyRegistrationOptions({ + userId: ctx.user.id, + }); + }); diff --git a/packages/trpc/server/auth-router/create-passkey-registration-options.types.ts b/packages/trpc/server/auth-router/create-passkey-registration-options.types.ts new file mode 100644 index 000000000..317201d3c --- /dev/null +++ b/packages/trpc/server/auth-router/create-passkey-registration-options.types.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const ZCreatePasskeyRegistrationOptionsRequestSchema = z.void(); + +export const ZCreatePasskeyRegistrationOptionsResponseSchema = z.any(); // PublicKeyCredentialCreationOptions type + +export type TCreatePasskeyRegistrationOptionsRequest = z.infer< + typeof ZCreatePasskeyRegistrationOptionsRequestSchema +>; +export type TCreatePasskeyRegistrationOptionsResponse = z.infer< + typeof ZCreatePasskeyRegistrationOptionsResponseSchema +>; diff --git a/packages/trpc/server/auth-router/create-passkey-signin-options.ts b/packages/trpc/server/auth-router/create-passkey-signin-options.ts new file mode 100644 index 000000000..924db1821 --- /dev/null +++ b/packages/trpc/server/auth-router/create-passkey-signin-options.ts @@ -0,0 +1,24 @@ +import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options'; +import { nanoid } from '@documenso/lib/universal/id'; + +import { procedure } from '../trpc'; +import { + ZCreatePasskeySigninOptionsRequestSchema, + ZCreatePasskeySigninOptionsResponseSchema, +} from './create-passkey-signin-options.types'; + +export const createPasskeySigninOptionsRoute = procedure + .input(ZCreatePasskeySigninOptionsRequestSchema) + .output(ZCreatePasskeySigninOptionsResponseSchema) + .mutation(async () => { + const sessionIdToken = nanoid(16); + + const [sessionId] = decodeURI(sessionIdToken).split('|'); + + const options = await createPasskeySigninOptions({ sessionId }); + + return { + options, + sessionId, + }; + }); diff --git a/packages/trpc/server/auth-router/create-passkey-signin-options.types.ts b/packages/trpc/server/auth-router/create-passkey-signin-options.types.ts new file mode 100644 index 000000000..38585372a --- /dev/null +++ b/packages/trpc/server/auth-router/create-passkey-signin-options.types.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const ZCreatePasskeySigninOptionsRequestSchema = z.void(); + +export const ZCreatePasskeySigninOptionsResponseSchema = z.object({ + options: z.any(), // PublicKeyCredentialRequestOptions type + sessionId: z.string(), +}); + +export type TCreatePasskeySigninOptionsRequest = z.infer< + typeof ZCreatePasskeySigninOptionsRequestSchema +>; +export type TCreatePasskeySigninOptionsResponse = z.infer< + typeof ZCreatePasskeySigninOptionsResponseSchema +>; diff --git a/packages/trpc/server/auth-router/create-passkey.ts b/packages/trpc/server/auth-router/create-passkey.ts new file mode 100644 index 000000000..42b97b45b --- /dev/null +++ b/packages/trpc/server/auth-router/create-passkey.ts @@ -0,0 +1,21 @@ +import type { RegistrationResponseJSON } from '@simplewebauthn/types'; + +import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey'; + +import { authenticatedProcedure } from '../trpc'; +import { ZCreatePasskeyRequestSchema, ZCreatePasskeyResponseSchema } from './create-passkey.types'; + +export const createPasskeyRoute = authenticatedProcedure + .input(ZCreatePasskeyRequestSchema) + .output(ZCreatePasskeyResponseSchema) + .mutation(async ({ ctx, input }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const verificationResponse = input.verificationResponse as RegistrationResponseJSON; + + return await createPasskey({ + userId: ctx.user.id, + verificationResponse, + passkeyName: input.passkeyName, + requestMetadata: ctx.metadata.requestMetadata, + }); + }); diff --git a/packages/trpc/server/auth-router/create-passkey.types.ts b/packages/trpc/server/auth-router/create-passkey.types.ts new file mode 100644 index 000000000..06abf1793 --- /dev/null +++ b/packages/trpc/server/auth-router/create-passkey.types.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn'; + +export const ZCreatePasskeyRequestSchema = z.object({ + passkeyName: z.string().trim().min(1), + verificationResponse: ZRegistrationResponseJSONSchema, +}); + +export const ZCreatePasskeyResponseSchema = z.void(); + +export type TCreatePasskeyRequest = z.infer; +export type TCreatePasskeyResponse = z.infer; diff --git a/packages/trpc/server/auth-router/delete-passkey.ts b/packages/trpc/server/auth-router/delete-passkey.ts new file mode 100644 index 000000000..af1cdc6b7 --- /dev/null +++ b/packages/trpc/server/auth-router/delete-passkey.ts @@ -0,0 +1,23 @@ +import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey'; + +import { authenticatedProcedure } from '../trpc'; +import { ZDeletePasskeyRequestSchema, ZDeletePasskeyResponseSchema } from './delete-passkey.types'; + +export const deletePasskeyRoute = authenticatedProcedure + .input(ZDeletePasskeyRequestSchema) + .output(ZDeletePasskeyResponseSchema) + .mutation(async ({ ctx, input }) => { + const { passkeyId } = input; + + ctx.logger.info({ + input: { + passkeyId, + }, + }); + + await deletePasskey({ + userId: ctx.user.id, + passkeyId, + requestMetadata: ctx.metadata.requestMetadata, + }); + }); diff --git a/packages/trpc/server/auth-router/delete-passkey.types.ts b/packages/trpc/server/auth-router/delete-passkey.types.ts new file mode 100644 index 000000000..7cfc4eacf --- /dev/null +++ b/packages/trpc/server/auth-router/delete-passkey.types.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const ZDeletePasskeyRequestSchema = z.object({ + passkeyId: z.string().trim().min(1), +}); + +export const ZDeletePasskeyResponseSchema = z.void(); + +export type TDeletePasskeyRequest = z.infer; +export type TDeletePasskeyResponse = z.infer; diff --git a/packages/trpc/server/auth-router/find-passkeys.ts b/packages/trpc/server/auth-router/find-passkeys.ts new file mode 100644 index 000000000..ff7e5be79 --- /dev/null +++ b/packages/trpc/server/auth-router/find-passkeys.ts @@ -0,0 +1,18 @@ +import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys'; + +import { authenticatedProcedure } from '../trpc'; +import { ZFindPasskeysRequestSchema, ZFindPasskeysResponseSchema } from './find-passkeys.types'; + +export const findPasskeysRoute = authenticatedProcedure + .input(ZFindPasskeysRequestSchema) + .output(ZFindPasskeysResponseSchema) + .query(async ({ input, ctx }) => { + const { page, perPage, orderBy } = input; + + return await findPasskeys({ + page, + perPage, + orderBy, + userId: ctx.user.id, + }); + }); diff --git a/packages/trpc/server/auth-router/find-passkeys.types.ts b/packages/trpc/server/auth-router/find-passkeys.types.ts new file mode 100644 index 000000000..1982f430e --- /dev/null +++ b/packages/trpc/server/auth-router/find-passkeys.types.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; +import PasskeySchema from '@documenso/prisma/generated/zod/modelSchema/PasskeySchema'; + +export const ZFindPasskeysRequestSchema = ZFindSearchParamsSchema.extend({ + orderBy: z + .object({ + column: z.enum(['createdAt', 'updatedAt', 'name']), + direction: z.enum(['asc', 'desc']), + }) + .optional(), +}); + +export const ZFindPasskeysResponseSchema = ZFindResultResponse.extend({ + data: z.array( + PasskeySchema.pick({ + id: true, + userId: true, + name: true, + createdAt: true, + updatedAt: true, + lastUsedAt: true, + counter: true, + credentialDeviceType: true, + credentialBackedUp: true, + transports: true, + }), + ), +}); + +export type TFindPasskeysRequest = z.infer; +export type TFindPasskeysResponse = z.infer; diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 2eb54d1e6..5fc4b2c99 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -1,113 +1,20 @@ -import type { RegistrationResponseJSON } from '@simplewebauthn/types'; - -import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey'; -import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options'; -import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options'; -import { createPasskeySigninOptions } from '@documenso/lib/server-only/auth/create-passkey-signin-options'; -import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey'; -import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys'; -import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey'; -import { nanoid } from '@documenso/lib/universal/id'; - -import { authenticatedProcedure, procedure, router } from '../trpc'; -import { - ZCreatePasskeyAuthenticationOptionsMutationSchema, - ZCreatePasskeyMutationSchema, - ZDeletePasskeyMutationSchema, - ZFindPasskeysQuerySchema, - ZUpdatePasskeyMutationSchema, -} from './schema'; +import { router } from '../trpc'; +import { createPasskeyRoute } from './create-passkey'; +import { createPasskeyAuthenticationOptionsRoute } from './create-passkey-authentication-options'; +import { createPasskeyRegistrationOptionsRoute } from './create-passkey-registration-options'; +import { createPasskeySigninOptionsRoute } from './create-passkey-signin-options'; +import { deletePasskeyRoute } from './delete-passkey'; +import { findPasskeysRoute } from './find-passkeys'; +import { updatePasskeyRoute } from './update-passkey'; export const authRouter = router({ - createPasskey: authenticatedProcedure - .input(ZCreatePasskeyMutationSchema) - .mutation(async ({ ctx, input }) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const verificationResponse = input.verificationResponse as RegistrationResponseJSON; - - return await createPasskey({ - userId: ctx.user.id, - verificationResponse, - passkeyName: input.passkeyName, - requestMetadata: ctx.metadata.requestMetadata, - }); - }), - - createPasskeyAuthenticationOptions: authenticatedProcedure - .input(ZCreatePasskeyAuthenticationOptionsMutationSchema) - .mutation(async ({ ctx, input }) => { - return await createPasskeyAuthenticationOptions({ - userId: ctx.user.id, - preferredPasskeyId: input?.preferredPasskeyId, - }); - }), - - createPasskeyRegistrationOptions: authenticatedProcedure.mutation(async ({ ctx }) => { - return await createPasskeyRegistrationOptions({ - userId: ctx.user.id, - }); + passkey: router({ + create: createPasskeyRoute, + createAuthenticationOptions: createPasskeyAuthenticationOptionsRoute, + createRegistrationOptions: createPasskeyRegistrationOptionsRoute, + createSigninOptions: createPasskeySigninOptionsRoute, + delete: deletePasskeyRoute, + find: findPasskeysRoute, + update: updatePasskeyRoute, }), - - createPasskeySigninOptions: procedure.mutation(async () => { - const sessionIdToken = nanoid(16); - - const [sessionId] = decodeURI(sessionIdToken).split('|'); - - const options = await createPasskeySigninOptions({ sessionId }); - - return { - options, - sessionId, - }; - }), - - deletePasskey: authenticatedProcedure - .input(ZDeletePasskeyMutationSchema) - .mutation(async ({ ctx, input }) => { - const { passkeyId } = input; - - ctx.logger.info({ - input: { - passkeyId, - }, - }); - - await deletePasskey({ - userId: ctx.user.id, - passkeyId, - requestMetadata: ctx.metadata.requestMetadata, - }); - }), - - findPasskeys: authenticatedProcedure - .input(ZFindPasskeysQuerySchema) - .query(async ({ input, ctx }) => { - const { page, perPage, orderBy } = input; - - return await findPasskeys({ - page, - perPage, - orderBy, - userId: ctx.user.id, - }); - }), - - updatePasskey: authenticatedProcedure - .input(ZUpdatePasskeyMutationSchema) - .mutation(async ({ ctx, input }) => { - const { passkeyId, name } = input; - - ctx.logger.info({ - input: { - passkeyId, - }, - }); - - await updatePasskey({ - userId: ctx.user.id, - passkeyId, - name, - requestMetadata: ctx.metadata.requestMetadata, - }); - }), }); diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index 55ea2167d..91c8d5d4e 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -1,8 +1,5 @@ import { z } from 'zod'; -import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; -import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn'; - export const ZCurrentPasswordSchema = z .string() .min(6, { message: 'Must be at least 6 characters in length' }) @@ -24,50 +21,3 @@ export const ZPasswordSchema = z .refine((value) => value.length > 25 || /[`~<>?,./!@#$%^&*()\-_"'+=|{}[\];:\\]/.test(value), { message: 'One special character is required', }); - -export const ZSignUpMutationSchema = z.object({ - name: z.string().min(1), - email: z.string().email(), - password: ZPasswordSchema, - signature: z.string().nullish(), - url: z - .string() - .trim() - .toLowerCase() - .min(1) - .regex(/^[a-z0-9-]+$/, { - message: 'Username can only container alphanumeric characters and dashes.', - }) - .optional(), -}); - -export const ZCreatePasskeyMutationSchema = z.object({ - passkeyName: z.string().trim().min(1), - verificationResponse: ZRegistrationResponseJSONSchema, -}); - -export const ZCreatePasskeyAuthenticationOptionsMutationSchema = z - .object({ - preferredPasskeyId: z.string().optional(), - }) - .optional(); - -export const ZDeletePasskeyMutationSchema = z.object({ - passkeyId: z.string().trim().min(1), -}); - -export const ZUpdatePasskeyMutationSchema = z.object({ - passkeyId: z.string().trim().min(1), - name: z.string().trim().min(1), -}); - -export const ZFindPasskeysQuerySchema = ZFindSearchParamsSchema.extend({ - orderBy: z - .object({ - column: z.enum(['createdAt', 'updatedAt', 'name']), - direction: z.enum(['asc', 'desc']), - }) - .optional(), -}); - -export type TSignUpMutationSchema = z.infer; diff --git a/packages/trpc/server/auth-router/update-passkey.ts b/packages/trpc/server/auth-router/update-passkey.ts new file mode 100644 index 000000000..eeec06653 --- /dev/null +++ b/packages/trpc/server/auth-router/update-passkey.ts @@ -0,0 +1,24 @@ +import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey'; + +import { authenticatedProcedure } from '../trpc'; +import { ZUpdatePasskeyRequestSchema, ZUpdatePasskeyResponseSchema } from './update-passkey.types'; + +export const updatePasskeyRoute = authenticatedProcedure + .input(ZUpdatePasskeyRequestSchema) + .output(ZUpdatePasskeyResponseSchema) + .mutation(async ({ ctx, input }) => { + const { passkeyId, name } = input; + + ctx.logger.info({ + input: { + passkeyId, + }, + }); + + await updatePasskey({ + userId: ctx.user.id, + passkeyId, + name, + requestMetadata: ctx.metadata.requestMetadata, + }); + }); diff --git a/packages/trpc/server/auth-router/update-passkey.types.ts b/packages/trpc/server/auth-router/update-passkey.types.ts new file mode 100644 index 000000000..e898234da --- /dev/null +++ b/packages/trpc/server/auth-router/update-passkey.types.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const ZUpdatePasskeyRequestSchema = z.object({ + passkeyId: z.string().trim().min(1), + name: z.string().trim().min(1), +}); + +export const ZUpdatePasskeyResponseSchema = z.void(); + +export type TUpdatePasskeyRequest = z.infer; +export type TUpdatePasskeyResponse = z.infer; diff --git a/packages/trpc/server/document-router/create-document-temporary.ts b/packages/trpc/server/document-router/create-document-temporary.ts new file mode 100644 index 000000000..fdc64f1d6 --- /dev/null +++ b/packages/trpc/server/document-router/create-document-temporary.ts @@ -0,0 +1,81 @@ +import { DocumentDataType } from '@prisma/client'; + +import { getServerLimits } from '@documenso/ee/server-only/limits/server'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; +import { createDocumentV2 } from '@documenso/lib/server-only/document/create-document-v2'; +import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZCreateDocumentTemporaryRequestSchema, + ZCreateDocumentTemporaryResponseSchema, + createDocumentTemporaryMeta, +} from './create-document-temporary.types'; + +/** + * Temporariy endpoint for V2 Beta until we allow passthrough documents on create. + * + * @public + * @deprecated + */ +export const createDocumentTemporaryRoute = authenticatedProcedure + .meta(createDocumentTemporaryMeta) + .input(ZCreateDocumentTemporaryRequestSchema) + .output(ZCreateDocumentTemporaryResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + + const { + title, + externalId, + visibility, + globalAccessAuth, + globalActionAuth, + recipients, + meta, + folderId, + } = input; + + const { remaining } = await getServerLimits({ userId: user.id, teamId }); + + if (remaining.documents <= 0) { + throw new AppError(AppErrorCode.LIMIT_EXCEEDED, { + message: 'You have reached your document limit for this month. Please upgrade your plan.', + statusCode: 400, + }); + } + + const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`; + + const { url, key } = await getPresignPostUrl(fileName, 'application/pdf'); + + const documentData = await createDocumentData({ + data: key, + type: DocumentDataType.S3_PATH, + }); + + const createdDocument = await createDocumentV2({ + userId: ctx.user.id, + teamId, + documentDataId: documentData.id, + normalizePdf: false, // Not normalizing because of presigned URL. + data: { + title, + externalId, + visibility, + globalAccessAuth, + globalActionAuth, + recipients, + folderId, + }, + meta, + requestMetadata: ctx.metadata, + }); + + return { + document: createdDocument, + folder: createdDocument.folder, // Todo: Remove this prior to api-v2 release. + uploadUrl: url, + }; + }); diff --git a/packages/trpc/server/document-router/create-document-temporary.types.ts b/packages/trpc/server/document-router/create-document-temporary.types.ts new file mode 100644 index 000000000..858b3835a --- /dev/null +++ b/packages/trpc/server/document-router/create-document-temporary.types.ts @@ -0,0 +1,120 @@ +import { DocumentSigningOrder } from '@prisma/client'; +import { z } from 'zod'; + +import { ZDocumentSchema } from '@documenso/lib/types/document'; +import { + ZDocumentAccessAuthTypesSchema, + ZDocumentActionAuthTypesSchema, +} from '@documenso/lib/types/document-auth'; +import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; +import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values'; +import { + ZFieldHeightSchema, + ZFieldPageNumberSchema, + ZFieldPageXSchema, + ZFieldPageYSchema, + ZFieldWidthSchema, +} from '@documenso/lib/types/field'; +import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; + +import { ZCreateRecipientSchema } from '../recipient-router/schema'; +import type { TrpcRouteMeta } from '../trpc'; +import { + ZDocumentExternalIdSchema, + ZDocumentMetaDateFormatSchema, + ZDocumentMetaDistributionMethodSchema, + ZDocumentMetaDrawSignatureEnabledSchema, + ZDocumentMetaLanguageSchema, + ZDocumentMetaMessageSchema, + ZDocumentMetaRedirectUrlSchema, + ZDocumentMetaSubjectSchema, + ZDocumentMetaTimezoneSchema, + ZDocumentMetaTypedSignatureEnabledSchema, + ZDocumentMetaUploadSignatureEnabledSchema, + ZDocumentTitleSchema, + ZDocumentVisibilitySchema, +} from './schema'; + +/** + * Temporariy endpoint for V2 Beta until we allow passthrough documents on create. + */ +export const createDocumentTemporaryMeta: TrpcRouteMeta = { + openapi: { + method: 'POST', + path: '/document/create/beta', + summary: 'Create document', + description: + 'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.', + tags: ['Document'], + }, +}; + +export const ZCreateDocumentTemporaryRequestSchema = z.object({ + title: ZDocumentTitleSchema, + externalId: ZDocumentExternalIdSchema.optional(), + visibility: ZDocumentVisibilitySchema.optional(), + globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(), + globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(), + formValues: ZDocumentFormValuesSchema.optional(), + folderId: z + .string() + .describe( + 'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.', + ) + .optional(), + recipients: z + .array( + ZCreateRecipientSchema.extend({ + fields: ZFieldAndMetaSchema.and( + z.object({ + pageNumber: ZFieldPageNumberSchema, + pageX: ZFieldPageXSchema, + pageY: ZFieldPageYSchema, + width: ZFieldWidthSchema, + height: ZFieldHeightSchema, + }), + ) + .array() + .optional(), + }), + ) + .refine( + (recipients) => { + const emails = recipients.map((recipient) => recipient.email); + + return new Set(emails).size === emails.length; + }, + { message: 'Recipients must have unique emails' }, + ) + .optional(), + meta: z + .object({ + subject: ZDocumentMetaSubjectSchema.optional(), + message: ZDocumentMetaMessageSchema.optional(), + timezone: ZDocumentMetaTimezoneSchema.optional(), + dateFormat: ZDocumentMetaDateFormatSchema.optional(), + distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(), + signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), + redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), + language: ZDocumentMetaLanguageSchema.optional(), + typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), + drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(), + uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(), + emailSettings: ZDocumentEmailSettingsSchema.optional(), + }) + .optional(), +}); + +export const ZCreateDocumentTemporaryResponseSchema = z.object({ + document: ZDocumentSchema, + uploadUrl: z + .string() + .describe( + 'The URL to upload the document PDF to. Use a PUT request with the file via form-data', + ), +}); + +export type TCreateDocumentTemporaryRequest = z.infer; +export type TCreateDocumentTemporaryResponse = z.infer< + typeof ZCreateDocumentTemporaryResponseSchema +>; diff --git a/packages/trpc/server/document-router/create-document.ts b/packages/trpc/server/document-router/create-document.ts new file mode 100644 index 000000000..bf66f93d3 --- /dev/null +++ b/packages/trpc/server/document-router/create-document.ts @@ -0,0 +1,47 @@ +import { getServerLimits } from '@documenso/ee/server-only/limits/server'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { createDocument } from '@documenso/lib/server-only/document/create-document'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZCreateDocumentRequestSchema, + ZCreateDocumentResponseSchema, +} from './create-document.types'; + +export const createDocumentRoute = authenticatedProcedure + .input(ZCreateDocumentRequestSchema) + .output(ZCreateDocumentResponseSchema) + .mutation(async ({ input, ctx }) => { + const { user, teamId } = ctx; + const { title, documentDataId, timezone, folderId } = input; + + ctx.logger.info({ + input: { + folderId, + }, + }); + + const { remaining } = await getServerLimits({ userId: user.id, teamId }); + + if (remaining.documents <= 0) { + throw new AppError(AppErrorCode.LIMIT_EXCEEDED, { + message: 'You have reached your document limit for this month. Please upgrade your plan.', + statusCode: 400, + }); + } + + const document = await createDocument({ + userId: user.id, + teamId, + title, + documentDataId, + normalizePdf: true, + userTimezone: timezone, + requestMetadata: ctx.metadata, + folderId, + }); + + return { + id: document.id, + }; + }); diff --git a/packages/trpc/server/document-router/create-document.types.ts b/packages/trpc/server/document-router/create-document.types.ts new file mode 100644 index 000000000..4dfc89dce --- /dev/null +++ b/packages/trpc/server/document-router/create-document.types.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { ZDocumentMetaTimezoneSchema, ZDocumentTitleSchema } from './schema'; + +// Currently not in use until we allow passthrough documents on create. +// export const createDocumentMeta: TrpcRouteMeta = { +// openapi: { +// method: 'POST', +// path: '/document/create', +// summary: 'Create document', +// tags: ['Document'], +// }, +// }; + +export const ZCreateDocumentRequestSchema = z.object({ + title: ZDocumentTitleSchema, + documentDataId: z.string().min(1), + timezone: ZDocumentMetaTimezoneSchema.optional(), + folderId: z.string().describe('The ID of the folder to create the document in').optional(), +}); + +export const ZCreateDocumentResponseSchema = z.object({ + id: z.number(), +}); + +export type TCreateDocumentRequest = z.infer; +export type TCreateDocumentResponse = z.infer; diff --git a/packages/trpc/server/document-router/delete-document.ts b/packages/trpc/server/document-router/delete-document.ts new file mode 100644 index 000000000..659ff0394 --- /dev/null +++ b/packages/trpc/server/document-router/delete-document.ts @@ -0,0 +1,35 @@ +import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZDeleteDocumentRequestSchema, + ZDeleteDocumentResponseSchema, + deleteDocumentMeta, +} from './delete-document.types'; +import { ZGenericSuccessResponse } from './schema'; + +export const deleteDocumentRoute = authenticatedProcedure + .meta(deleteDocumentMeta) + .input(ZDeleteDocumentRequestSchema) + .output(ZDeleteDocumentResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId } = input; + + ctx.logger.info({ + input: { + documentId, + }, + }); + + const userId = ctx.user.id; + + await deleteDocument({ + id: documentId, + userId, + teamId, + requestMetadata: ctx.metadata, + }); + + return ZGenericSuccessResponse; + }); diff --git a/packages/trpc/server/document-router/delete-document.types.ts b/packages/trpc/server/document-router/delete-document.types.ts new file mode 100644 index 000000000..72c7a711d --- /dev/null +++ b/packages/trpc/server/document-router/delete-document.types.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import type { TrpcRouteMeta } from '../trpc'; +import { ZSuccessResponseSchema } from './schema'; + +export const deleteDocumentMeta: TrpcRouteMeta = { + openapi: { + method: 'POST', + path: '/document/delete', + summary: 'Delete document', + tags: ['Document'], + }, +}; + +export const ZDeleteDocumentRequestSchema = z.object({ + documentId: z.number(), +}); + +export const ZDeleteDocumentResponseSchema = ZSuccessResponseSchema; + +export type TDeleteDocumentRequest = z.infer; +export type TDeleteDocumentResponse = z.infer; diff --git a/packages/trpc/server/document-router/distribute-document.ts b/packages/trpc/server/document-router/distribute-document.ts new file mode 100644 index 000000000..00fe8ef92 --- /dev/null +++ b/packages/trpc/server/document-router/distribute-document.ts @@ -0,0 +1,50 @@ +import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; +import { sendDocument } from '@documenso/lib/server-only/document/send-document'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZDistributeDocumentRequestSchema, + ZDistributeDocumentResponseSchema, + distributeDocumentMeta, +} from './distribute-document.types'; + +export const distributeDocumentRoute = authenticatedProcedure + .meta(distributeDocumentMeta) + .input(ZDistributeDocumentRequestSchema) + .output(ZDistributeDocumentResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId, meta = {} } = input; + + ctx.logger.info({ + input: { + documentId, + }, + }); + + if (Object.values(meta).length > 0) { + await upsertDocumentMeta({ + userId: ctx.user.id, + teamId, + documentId, + subject: meta.subject, + message: meta.message, + dateFormat: meta.dateFormat, + timezone: meta.timezone, + redirectUrl: meta.redirectUrl, + distributionMethod: meta.distributionMethod, + emailSettings: meta.emailSettings, + language: meta.language, + emailId: meta.emailId, + emailReplyTo: meta.emailReplyTo, + requestMetadata: ctx.metadata, + }); + } + + return await sendDocument({ + userId: ctx.user.id, + documentId, + teamId, + requestMetadata: ctx.metadata, + }); + }); diff --git a/packages/trpc/server/document-router/distribute-document.types.ts b/packages/trpc/server/document-router/distribute-document.types.ts new file mode 100644 index 000000000..41bf23eb2 --- /dev/null +++ b/packages/trpc/server/document-router/distribute-document.types.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +import { ZDocumentLiteSchema } from '@documenso/lib/types/document'; +import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; + +import type { TrpcRouteMeta } from '../trpc'; +import { + ZDocumentMetaDateFormatSchema, + ZDocumentMetaDistributionMethodSchema, + ZDocumentMetaLanguageSchema, + ZDocumentMetaMessageSchema, + ZDocumentMetaRedirectUrlSchema, + ZDocumentMetaSubjectSchema, + ZDocumentMetaTimezoneSchema, +} from './schema'; + +export const distributeDocumentMeta: TrpcRouteMeta = { + openapi: { + method: 'POST', + path: '/document/distribute', + summary: 'Distribute document', + description: 'Send the document out to recipients based on your distribution method', + tags: ['Document'], + }, +}; + +export const ZDistributeDocumentRequestSchema = z.object({ + documentId: z.number().describe('The ID of the document to send.'), + meta: z + .object({ + subject: ZDocumentMetaSubjectSchema.optional(), + message: ZDocumentMetaMessageSchema.optional(), + timezone: ZDocumentMetaTimezoneSchema.optional(), + dateFormat: ZDocumentMetaDateFormatSchema.optional(), + distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(), + redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), + language: ZDocumentMetaLanguageSchema.optional(), + emailId: z.string().nullish(), + emailReplyTo: z.string().email().nullish(), + emailSettings: ZDocumentEmailSettingsSchema.optional(), + }) + .optional(), +}); + +export const ZDistributeDocumentResponseSchema = ZDocumentLiteSchema; + +export type TDistributeDocumentRequest = z.infer; +export type TDistributeDocumentResponse = z.infer; diff --git a/packages/trpc/server/document-router/download-document-audit-logs.ts b/packages/trpc/server/document-router/download-document-audit-logs.ts new file mode 100644 index 000000000..af84c43e0 --- /dev/null +++ b/packages/trpc/server/document-router/download-document-audit-logs.ts @@ -0,0 +1,47 @@ +import { DateTime } from 'luxon'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZDownloadDocumentAuditLogsRequestSchema, + ZDownloadDocumentAuditLogsResponseSchema, +} from './download-document-audit-logs.types'; + +export const downloadDocumentAuditLogsRoute = authenticatedProcedure + .input(ZDownloadDocumentAuditLogsRequestSchema) + .output(ZDownloadDocumentAuditLogsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId } = input; + + ctx.logger.info({ + input: { + documentId, + }, + }); + + const document = await getDocumentById({ + documentId, + userId: ctx.user.id, + teamId, + }).catch(() => null); + + if (!document || (teamId && document.teamId !== teamId)) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have access to this document.', + }); + } + + const encrypted = encryptSecondaryData({ + data: document.id.toString(), + expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + }); + + return { + url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`, + }; + }); diff --git a/packages/trpc/server/document-router/download-document-audit-logs.types.ts b/packages/trpc/server/document-router/download-document-audit-logs.types.ts new file mode 100644 index 000000000..b4cc209c2 --- /dev/null +++ b/packages/trpc/server/document-router/download-document-audit-logs.types.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const ZDownloadDocumentAuditLogsRequestSchema = z.object({ + documentId: z.number(), +}); + +export const ZDownloadDocumentAuditLogsResponseSchema = z.object({ + url: z.string(), +}); + +export type TDownloadDocumentAuditLogsRequest = z.infer< + typeof ZDownloadDocumentAuditLogsRequestSchema +>; +export type TDownloadDocumentAuditLogsResponse = z.infer< + typeof ZDownloadDocumentAuditLogsResponseSchema +>; diff --git a/packages/trpc/server/document-router/download-document-certificate.ts b/packages/trpc/server/document-router/download-document-certificate.ts new file mode 100644 index 000000000..b59eafbf0 --- /dev/null +++ b/packages/trpc/server/document-router/download-document-certificate.ts @@ -0,0 +1,46 @@ +import { DateTime } from 'luxon'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; +import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZDownloadDocumentCertificateRequestSchema, + ZDownloadDocumentCertificateResponseSchema, +} from './download-document-certificate.types'; + +export const downloadDocumentCertificateRoute = authenticatedProcedure + .input(ZDownloadDocumentCertificateRequestSchema) + .output(ZDownloadDocumentCertificateResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId } = input; + + ctx.logger.info({ + input: { + documentId, + }, + }); + + const document = await getDocumentById({ + documentId, + userId: ctx.user.id, + teamId, + }); + + if (!isDocumentCompleted(document.status)) { + throw new AppError('DOCUMENT_NOT_COMPLETE'); + } + + const encrypted = encryptSecondaryData({ + data: document.id.toString(), + expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + }); + + return { + url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`, + }; + }); diff --git a/packages/trpc/server/document-router/download-document-certificate.types.ts b/packages/trpc/server/document-router/download-document-certificate.types.ts new file mode 100644 index 000000000..df81f1cad --- /dev/null +++ b/packages/trpc/server/document-router/download-document-certificate.types.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const ZDownloadDocumentCertificateRequestSchema = z.object({ + documentId: z.number(), +}); + +export const ZDownloadDocumentCertificateResponseSchema = z.object({ + url: z.string(), +}); + +export type TDownloadDocumentCertificateRequest = z.infer< + typeof ZDownloadDocumentCertificateRequestSchema +>; +export type TDownloadDocumentCertificateResponse = z.infer< + typeof ZDownloadDocumentCertificateResponseSchema +>; diff --git a/packages/trpc/server/document-router/download-document.ts b/packages/trpc/server/document-router/download-document.ts index 84b75265c..a0cbbf104 100644 --- a/packages/trpc/server/document-router/download-document.ts +++ b/packages/trpc/server/document-router/download-document.ts @@ -6,18 +6,14 @@ import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { authenticatedProcedure } from '../trpc'; -import { ZDownloadDocumentRequestSchema, ZDownloadDocumentResponseSchema } from './schema'; +import { + ZDownloadDocumentRequestSchema, + ZDownloadDocumentResponseSchema, + downloadDocumentMeta, +} from './download-document.types'; export const downloadDocumentRoute = authenticatedProcedure - .meta({ - openapi: { - method: 'GET', - path: '/document/{documentId}/download-beta', - summary: 'Download document (beta)', - description: 'Get a pre-signed download URL for the original or signed version of a document', - tags: ['Document'], - }, - }) + .meta(downloadDocumentMeta) .input(ZDownloadDocumentRequestSchema) .output(ZDownloadDocumentResponseSchema) .query(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/download-document.types.ts b/packages/trpc/server/document-router/download-document.types.ts new file mode 100644 index 000000000..be4f454f8 --- /dev/null +++ b/packages/trpc/server/document-router/download-document.types.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +import type { TrpcRouteMeta } from '../trpc'; + +export const downloadDocumentMeta: TrpcRouteMeta = { + openapi: { + method: 'GET', + path: '/document/{documentId}/download-beta', + summary: 'Download document (beta)', + description: 'Get a pre-signed download URL for the original or signed version of a document', + tags: ['Document'], + }, +}; + +export const ZDownloadDocumentRequestSchema = z.object({ + documentId: z.number().describe('The ID of the document to download.'), + version: z + .enum(['original', 'signed']) + .describe( + 'The version of the document to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.', + ) + .default('signed'), +}); + +export const ZDownloadDocumentResponseSchema = z.object({ + downloadUrl: z.string().describe('Pre-signed URL for downloading the PDF file'), + filename: z.string().describe('The filename of the PDF file'), + contentType: z.string().describe('MIME type of the file'), +}); + +export type TDownloadDocumentRequest = z.infer; +export type TDownloadDocumentResponse = z.infer; diff --git a/packages/trpc/server/document-router/duplicate-document.ts b/packages/trpc/server/document-router/duplicate-document.ts new file mode 100644 index 000000000..be8f29d03 --- /dev/null +++ b/packages/trpc/server/document-router/duplicate-document.ts @@ -0,0 +1,29 @@ +import { duplicateDocument } from '@documenso/lib/server-only/document/duplicate-document-by-id'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZDuplicateDocumentRequestSchema, + ZDuplicateDocumentResponseSchema, + duplicateDocumentMeta, +} from './duplicate-document.types'; + +export const duplicateDocumentRoute = authenticatedProcedure + .meta(duplicateDocumentMeta) + .input(ZDuplicateDocumentRequestSchema) + .output(ZDuplicateDocumentResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { documentId } = input; + + ctx.logger.info({ + input: { + documentId, + }, + }); + + return await duplicateDocument({ + userId: user.id, + teamId, + documentId, + }); + }); diff --git a/packages/trpc/server/document-router/duplicate-document.types.ts b/packages/trpc/server/document-router/duplicate-document.types.ts new file mode 100644 index 000000000..e77bccc73 --- /dev/null +++ b/packages/trpc/server/document-router/duplicate-document.types.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import type { TrpcRouteMeta } from '../trpc'; + +export const duplicateDocumentMeta: TrpcRouteMeta = { + openapi: { + method: 'POST', + path: '/document/duplicate', + summary: 'Duplicate document', + tags: ['Document'], + }, +}; + +export const ZDuplicateDocumentRequestSchema = z.object({ + documentId: z.number(), +}); + +export const ZDuplicateDocumentResponseSchema = z.object({ + documentId: z.number(), +}); + +export type TDuplicateDocumentRequest = z.infer; +export type TDuplicateDocumentResponse = z.infer; diff --git a/packages/trpc/server/document-router/find-document-audit-logs.ts b/packages/trpc/server/document-router/find-document-audit-logs.ts new file mode 100644 index 000000000..17de388a3 --- /dev/null +++ b/packages/trpc/server/document-router/find-document-audit-logs.ts @@ -0,0 +1,41 @@ +import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZFindDocumentAuditLogsRequestSchema, + ZFindDocumentAuditLogsResponseSchema, +} from './find-document-audit-logs.types'; + +export const findDocumentAuditLogsRoute = authenticatedProcedure + .input(ZFindDocumentAuditLogsRequestSchema) + .output(ZFindDocumentAuditLogsResponseSchema) + .query(async ({ input, ctx }) => { + const { teamId } = ctx; + + const { + page, + perPage, + documentId, + cursor, + filterForRecentActivity, + orderByColumn, + orderByDirection, + } = input; + + ctx.logger.info({ + input: { + documentId, + }, + }); + + return await findDocumentAuditLogs({ + userId: ctx.user.id, + teamId, + page, + perPage, + documentId, + cursor, + filterForRecentActivity, + orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, + }); + }); diff --git a/packages/trpc/server/document-router/find-document-audit-logs.types.ts b/packages/trpc/server/document-router/find-document-audit-logs.types.ts new file mode 100644 index 000000000..6e8991667 --- /dev/null +++ b/packages/trpc/server/document-router/find-document-audit-logs.types.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +import { ZDocumentAuditLogSchema } from '@documenso/lib/types/document-audit-logs'; +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; + +export const ZFindDocumentAuditLogsRequestSchema = ZFindSearchParamsSchema.extend({ + documentId: z.number().min(1), + cursor: z.string().optional(), + filterForRecentActivity: z.boolean().optional(), + orderByColumn: z.enum(['createdAt', 'type']).optional(), + orderByDirection: z.enum(['asc', 'desc']).default('desc'), +}); + +export const ZFindDocumentAuditLogsResponseSchema = ZFindResultResponse.extend({ + data: ZDocumentAuditLogSchema.array(), + nextCursor: z.string().optional(), +}); + +export type TFindDocumentAuditLogsRequest = z.infer; +export type TFindDocumentAuditLogsResponse = z.infer; diff --git a/packages/trpc/server/document-router/find-documents-internal.ts b/packages/trpc/server/document-router/find-documents-internal.ts new file mode 100644 index 000000000..47748771b --- /dev/null +++ b/packages/trpc/server/document-router/find-documents-internal.ts @@ -0,0 +1,74 @@ +import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; +import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats'; +import { getStats } from '@documenso/lib/server-only/document/get-stats'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZFindDocumentsInternalRequestSchema, + ZFindDocumentsInternalResponseSchema, +} from './find-documents-internal.types'; + +export const findDocumentsInternalRoute = authenticatedProcedure + .input(ZFindDocumentsInternalRequestSchema) + .output(ZFindDocumentsInternalResponseSchema) + .query(async ({ input, ctx }) => { + const { user, teamId } = ctx; + + const { + query, + templateId, + page, + perPage, + orderByDirection, + orderByColumn, + source, + status, + period, + senderIds, + folderId, + } = input; + + const getStatOptions: GetStatsInput = { + user, + period, + search: query, + folderId, + }; + + if (teamId) { + const team = await getTeamById({ userId: user.id, teamId }); + + getStatOptions.team = { + teamId: team.id, + teamEmail: team.teamEmail?.email, + senderIds, + currentTeamMemberRole: team.currentTeamRole, + currentUserEmail: user.email, + userId: user.id, + }; + } + + const [stats, documents] = await Promise.all([ + getStats(getStatOptions), + findDocuments({ + userId: user.id, + teamId, + query, + templateId, + page, + perPage, + source, + status, + period, + senderIds, + folderId, + orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, + }), + ]); + + return { + ...documents, + stats, + }; + }); diff --git a/packages/trpc/server/document-router/find-documents-internal.types.ts b/packages/trpc/server/document-router/find-documents-internal.types.ts new file mode 100644 index 000000000..16e8edb66 --- /dev/null +++ b/packages/trpc/server/document-router/find-documents-internal.types.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import { ZDocumentManySchema } from '@documenso/lib/types/document'; +import { ZFindResultResponse } from '@documenso/lib/types/search-params'; +import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; + +import { ZFindDocumentsRequestSchema } from './find-documents.types'; + +export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({ + period: z.enum(['7d', '14d', '30d']).optional(), + senderIds: z.array(z.number()).optional(), + status: z.nativeEnum(ExtendedDocumentStatus).optional(), + folderId: z.string().optional(), +}); + +export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({ + data: ZDocumentManySchema.array(), + stats: z.object({ + [ExtendedDocumentStatus.DRAFT]: z.number(), + [ExtendedDocumentStatus.PENDING]: z.number(), + [ExtendedDocumentStatus.COMPLETED]: z.number(), + [ExtendedDocumentStatus.REJECTED]: z.number(), + [ExtendedDocumentStatus.INBOX]: z.number(), + [ExtendedDocumentStatus.ALL]: z.number(), + }), +}); + +export type TFindDocumentsInternalRequest = z.infer; +export type TFindDocumentsInternalResponse = z.infer; diff --git a/packages/trpc/server/document-router/find-documents.ts b/packages/trpc/server/document-router/find-documents.ts new file mode 100644 index 000000000..71684b326 --- /dev/null +++ b/packages/trpc/server/document-router/find-documents.ts @@ -0,0 +1,43 @@ +import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZFindDocumentsMeta, + ZFindDocumentsRequestSchema, + ZFindDocumentsResponseSchema, +} from './find-documents.types'; + +export const findDocumentsRoute = authenticatedProcedure + .meta(ZFindDocumentsMeta) + .input(ZFindDocumentsRequestSchema) + .output(ZFindDocumentsResponseSchema) + .query(async ({ input, ctx }) => { + const { user, teamId } = ctx; + + const { + query, + templateId, + page, + perPage, + orderByDirection, + orderByColumn, + source, + status, + folderId, + } = input; + + const documents = await findDocuments({ + userId: user.id, + teamId, + templateId, + query, + source, + status, + page, + perPage, + folderId, + orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, + }); + + return documents; + }); diff --git a/packages/trpc/server/document-router/find-documents.types.ts b/packages/trpc/server/document-router/find-documents.types.ts new file mode 100644 index 000000000..dafb047b6 --- /dev/null +++ b/packages/trpc/server/document-router/find-documents.types.ts @@ -0,0 +1,42 @@ +import { DocumentSource, DocumentStatus } from '@prisma/client'; +import { z } from 'zod'; + +import { ZDocumentManySchema } from '@documenso/lib/types/document'; +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; + +import type { TrpcRouteMeta } from '../trpc'; + +export const ZFindDocumentsMeta: TrpcRouteMeta = { + openapi: { + method: 'GET', + path: '/document', + summary: 'Find documents', + description: 'Find documents based on a search criteria', + tags: ['Document'], + }, +}; + +export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({ + templateId: z + .number() + .describe('Filter documents by the template ID used to create it.') + .optional(), + source: z + .nativeEnum(DocumentSource) + .describe('Filter documents by how it was created.') + .optional(), + status: z + .nativeEnum(DocumentStatus) + .describe('Filter documents by the current status') + .optional(), + folderId: z.string().describe('Filter documents by folder ID').optional(), + orderByColumn: z.enum(['createdAt']).optional(), + orderByDirection: z.enum(['asc', 'desc']).describe('').default('desc'), +}); + +export const ZFindDocumentsResponseSchema = ZFindResultResponse.extend({ + data: ZDocumentManySchema.array(), +}); + +export type TFindDocumentsRequest = z.infer; +export type TFindDocumentsResponse = z.infer; diff --git a/packages/trpc/server/document-router/get-document-by-token.ts b/packages/trpc/server/document-router/get-document-by-token.ts new file mode 100644 index 000000000..b640f6946 --- /dev/null +++ b/packages/trpc/server/document-router/get-document-by-token.ts @@ -0,0 +1,43 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetDocumentByTokenRequestSchema, + ZGetDocumentByTokenResponseSchema, +} from './get-document-by-token.types'; + +export const getDocumentByTokenRoute = authenticatedProcedure + .input(ZGetDocumentByTokenRequestSchema) + .output(ZGetDocumentByTokenResponseSchema) + .query(async ({ input, ctx }) => { + const { token } = input; + + const document = await prisma.document.findFirst({ + where: { + recipients: { + some: { + token, + email: ctx.user.email, + }, + }, + }, + include: { + documentData: true, + }, + }); + + if (!document) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found', + }); + } + + ctx.logger.info({ + documentId: document.id, + }); + + return { + documentData: document.documentData, + }; + }); diff --git a/packages/trpc/server/document-router/get-document-by-token.types.ts b/packages/trpc/server/document-router/get-document-by-token.types.ts new file mode 100644 index 000000000..34f79c620 --- /dev/null +++ b/packages/trpc/server/document-router/get-document-by-token.types.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema'; + +export const ZGetDocumentByTokenRequestSchema = z.object({ + token: z.string().min(1), +}); + +export const ZGetDocumentByTokenResponseSchema = z.object({ + documentData: DocumentDataSchema, +}); + +export type TGetDocumentByTokenRequest = z.infer; +export type TGetDocumentByTokenResponse = z.infer; diff --git a/packages/trpc/server/document-router/get-document.ts b/packages/trpc/server/document-router/get-document.ts new file mode 100644 index 000000000..4dad5291f --- /dev/null +++ b/packages/trpc/server/document-router/get-document.ts @@ -0,0 +1,29 @@ +import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZGetDocumentRequestSchema, + ZGetDocumentResponseSchema, + getDocumentMeta, +} from './get-document.types'; + +export const getDocumentRoute = authenticatedProcedure + .meta(getDocumentMeta) + .input(ZGetDocumentRequestSchema) + .output(ZGetDocumentResponseSchema) + .query(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { documentId } = input; + + ctx.logger.info({ + input: { + documentId, + }, + }); + + return await getDocumentWithDetailsById({ + userId: user.id, + teamId, + documentId, + }); + }); diff --git a/packages/trpc/server/document-router/get-document.types.ts b/packages/trpc/server/document-router/get-document.types.ts new file mode 100644 index 000000000..08072f021 --- /dev/null +++ b/packages/trpc/server/document-router/get-document.types.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { ZDocumentSchema } from '@documenso/lib/types/document'; + +import type { TrpcRouteMeta } from '../trpc'; + +export const getDocumentMeta: TrpcRouteMeta = { + openapi: { + method: 'GET', + path: '/document/{documentId}', + summary: 'Get document', + description: 'Returns a document given an ID', + tags: ['Document'], + }, +}; + +export const ZGetDocumentRequestSchema = z.object({ + documentId: z.number(), +}); + +export const ZGetDocumentResponseSchema = ZDocumentSchema; + +export type TGetDocumentRequest = z.infer; +export type TGetDocumentResponse = z.infer; diff --git a/packages/trpc/server/document-router/redistribute-document.ts b/packages/trpc/server/document-router/redistribute-document.ts new file mode 100644 index 000000000..6bc06e189 --- /dev/null +++ b/packages/trpc/server/document-router/redistribute-document.ts @@ -0,0 +1,35 @@ +import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZRedistributeDocumentRequestSchema, + ZRedistributeDocumentResponseSchema, + redistributeDocumentMeta, +} from './redistribute-document.types'; +import { ZGenericSuccessResponse } from './schema'; + +export const redistributeDocumentRoute = authenticatedProcedure + .meta(redistributeDocumentMeta) + .input(ZRedistributeDocumentRequestSchema) + .output(ZRedistributeDocumentResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId, recipients } = input; + + ctx.logger.info({ + input: { + documentId, + recipients, + }, + }); + + await resendDocument({ + userId: ctx.user.id, + teamId, + documentId, + recipients, + requestMetadata: ctx.metadata, + }); + + return ZGenericSuccessResponse; + }); diff --git a/packages/trpc/server/document-router/redistribute-document.types.ts b/packages/trpc/server/document-router/redistribute-document.types.ts new file mode 100644 index 000000000..6444b6fe5 --- /dev/null +++ b/packages/trpc/server/document-router/redistribute-document.types.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +import type { TrpcRouteMeta } from '../trpc'; +import { ZSuccessResponseSchema } from './schema'; + +export const redistributeDocumentMeta: TrpcRouteMeta = { + openapi: { + method: 'POST', + path: '/document/redistribute', + summary: 'Redistribute document', + description: + 'Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document', + tags: ['Document'], + }, +}; + +export const ZRedistributeDocumentRequestSchema = z.object({ + documentId: z.number(), + recipients: z + .array(z.number()) + .min(1) + .describe('The IDs of the recipients to redistribute the document to.'), +}); + +export const ZRedistributeDocumentResponseSchema = ZSuccessResponseSchema; + +export type TRedistributeDocumentRequest = z.infer; +export type TRedistributeDocumentResponse = z.infer; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index e6da221ac..da5b8e769 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -1,689 +1,49 @@ -import { DocumentDataType } from '@prisma/client'; -import { DateTime } from 'luxon'; - -import { getServerLimits } from '@documenso/ee/server-only/limits/server'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; -import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; -import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; -import { createDocument } from '@documenso/lib/server-only/document/create-document'; -import { createDocumentV2 } from '@documenso/lib/server-only/document/create-document-v2'; -import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; -import { duplicateDocument } from '@documenso/lib/server-only/document/duplicate-document-by-id'; -import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; -import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; -import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; -import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; -import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; -import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats'; -import { getStats } from '@documenso/lib/server-only/document/get-stats'; -import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; -import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword'; -import { sendDocument } from '@documenso/lib/server-only/document/send-document'; -import { getTeamById } from '@documenso/lib/server-only/team/get-team'; -import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; -import { isDocumentCompleted } from '@documenso/lib/utils/document'; - -import { authenticatedProcedure, procedure, router } from '../trpc'; +import { router } from '../trpc'; +import { createDocumentRoute } from './create-document'; +import { createDocumentTemporaryRoute } from './create-document-temporary'; +import { deleteDocumentRoute } from './delete-document'; +import { distributeDocumentRoute } from './distribute-document'; import { downloadDocumentRoute } from './download-document'; +import { downloadDocumentAuditLogsRoute } from './download-document-audit-logs'; +import { downloadDocumentCertificateRoute } from './download-document-certificate'; +import { duplicateDocumentRoute } from './duplicate-document'; +import { findDocumentAuditLogsRoute } from './find-document-audit-logs'; +import { findDocumentsRoute } from './find-documents'; +import { findDocumentsInternalRoute } from './find-documents-internal'; import { findInboxRoute } from './find-inbox'; +import { getDocumentRoute } from './get-document'; +import { getDocumentByTokenRoute } from './get-document-by-token'; import { getInboxCountRoute } from './get-inbox-count'; -import { - ZCreateDocumentRequestSchema, - ZCreateDocumentV2RequestSchema, - ZCreateDocumentV2ResponseSchema, - ZDeleteDocumentMutationSchema, - ZDistributeDocumentRequestSchema, - ZDistributeDocumentResponseSchema, - ZDownloadAuditLogsMutationSchema, - ZDownloadCertificateMutationSchema, - ZDuplicateDocumentRequestSchema, - ZDuplicateDocumentResponseSchema, - ZFindDocumentAuditLogsQuerySchema, - ZFindDocumentsInternalRequestSchema, - ZFindDocumentsInternalResponseSchema, - ZFindDocumentsRequestSchema, - ZFindDocumentsResponseSchema, - ZGenericSuccessResponse, - ZGetDocumentByIdQuerySchema, - ZGetDocumentByTokenQuerySchema, - ZGetDocumentWithDetailsByIdRequestSchema, - ZGetDocumentWithDetailsByIdResponseSchema, - ZResendDocumentMutationSchema, - ZSearchDocumentsMutationSchema, - ZSetSigningOrderForDocumentMutationSchema, - ZSuccessResponseSchema, -} from './schema'; +import { redistributeDocumentRoute } from './redistribute-document'; +import { searchDocumentRoute } from './search-document'; import { updateDocumentRoute } from './update-document'; export const documentRouter = router({ - inbox: { + get: getDocumentRoute, + find: findDocumentsRoute, + create: createDocumentRoute, + update: updateDocumentRoute, + delete: deleteDocumentRoute, + duplicate: duplicateDocumentRoute, + downloadCertificate: downloadDocumentCertificateRoute, + distribute: distributeDocumentRoute, + redistribute: redistributeDocumentRoute, + search: searchDocumentRoute, + + // Temporary v2 beta routes to be removed once V2 is fully released. + download: downloadDocumentRoute, + createDocumentTemporary: createDocumentTemporaryRoute, + + // Internal document routes for custom frontend requests. + getDocumentByToken: getDocumentByTokenRoute, + findDocumentsInternal: findDocumentsInternalRoute, + + auditLog: { + find: findDocumentAuditLogsRoute, + download: downloadDocumentAuditLogsRoute, + }, + inbox: router({ find: findInboxRoute, getCount: getInboxCountRoute, - }, - updateDocument: updateDocumentRoute, - downloadDocument: downloadDocumentRoute, - - /** - * @private - */ - getDocumentById: authenticatedProcedure - .input(ZGetDocumentByIdQuerySchema) - .query(async ({ input, ctx }) => { - const { teamId } = ctx; - const { documentId } = input; - - ctx.logger.info({ - input: { - documentId, - }, - }); - - return await getDocumentById({ - userId: ctx.user.id, - teamId, - documentId, - }); - }), - - /** - * @private - */ - getDocumentByToken: procedure - .input(ZGetDocumentByTokenQuerySchema) - .query(async ({ input, ctx }) => { - const { token } = input; - - return await getDocumentAndSenderByToken({ - token, - userId: ctx.user?.id, - }); - }), - - /** - * @public - */ - findDocuments: authenticatedProcedure - .meta({ - openapi: { - method: 'GET', - path: '/document', - summary: 'Find documents', - description: 'Find documents based on a search criteria', - tags: ['Document'], - }, - }) - .input(ZFindDocumentsRequestSchema) - .output(ZFindDocumentsResponseSchema) - .query(async ({ input, ctx }) => { - const { user, teamId } = ctx; - - const { - query, - templateId, - page, - perPage, - orderByDirection, - orderByColumn, - source, - status, - folderId, - } = input; - - const documents = await findDocuments({ - userId: user.id, - teamId, - templateId, - query, - source, - status, - page, - perPage, - folderId, - orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, - }); - - return documents; - }), - - /** - * Internal endpoint for /documents page to additionally return getStats. - * - * @private - */ - findDocumentsInternal: authenticatedProcedure - .input(ZFindDocumentsInternalRequestSchema) - .output(ZFindDocumentsInternalResponseSchema) - .query(async ({ input, ctx }) => { - const { user, teamId } = ctx; - - const { - query, - templateId, - page, - perPage, - orderByDirection, - orderByColumn, - source, - status, - period, - senderIds, - folderId, - } = input; - - const getStatOptions: GetStatsInput = { - user, - period, - search: query, - folderId, - }; - - if (teamId) { - const team = await getTeamById({ userId: user.id, teamId }); - - getStatOptions.team = { - teamId: team.id, - teamEmail: team.teamEmail?.email, - senderIds, - currentTeamMemberRole: team.currentTeamRole, - currentUserEmail: user.email, - userId: user.id, - }; - } - - const [stats, documents] = await Promise.all([ - getStats(getStatOptions), - findDocuments({ - userId: user.id, - teamId, - query, - templateId, - page, - perPage, - source, - status, - period, - senderIds, - folderId, - orderBy: orderByColumn - ? { column: orderByColumn, direction: orderByDirection } - : undefined, - }), - ]); - - return { - ...documents, - stats, - }; - }), - - /** - * @public - * - * Todo: Refactor to getDocumentById. - */ - getDocumentWithDetailsById: authenticatedProcedure - .meta({ - openapi: { - method: 'GET', - path: '/document/{documentId}', - summary: 'Get document', - description: 'Returns a document given an ID', - tags: ['Document'], - }, - }) - .input(ZGetDocumentWithDetailsByIdRequestSchema) - .output(ZGetDocumentWithDetailsByIdResponseSchema) - .query(async ({ input, ctx }) => { - const { teamId, user } = ctx; - const { documentId, folderId } = input; - - ctx.logger.info({ - input: { - documentId, - folderId, - }, - }); - - return await getDocumentWithDetailsById({ - userId: user.id, - teamId, - documentId, - folderId, - }); - }), - - /** - * Temporariy endpoint for V2 Beta until we allow passthrough documents on create. - * - * @public - * @deprecated - */ - createDocumentTemporary: authenticatedProcedure - .meta({ - openapi: { - method: 'POST', - path: '/document/create/beta', - summary: 'Create document', - description: - 'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.', - tags: ['Document'], - }, - }) - .input(ZCreateDocumentV2RequestSchema) - .output(ZCreateDocumentV2ResponseSchema) - .mutation(async ({ input, ctx }) => { - const { teamId, user } = ctx; - - const { - title, - externalId, - visibility, - globalAccessAuth, - globalActionAuth, - recipients, - meta, - } = input; - - const { remaining } = await getServerLimits({ userId: user.id, teamId }); - - if (remaining.documents <= 0) { - throw new AppError(AppErrorCode.LIMIT_EXCEEDED, { - message: 'You have reached your document limit for this month. Please upgrade your plan.', - statusCode: 400, - }); - } - - const fileName = title.endsWith('.pdf') ? title : `${title}.pdf`; - - const { url, key } = await getPresignPostUrl(fileName, 'application/pdf'); - - const documentData = await createDocumentData({ - data: key, - type: DocumentDataType.S3_PATH, - }); - - const createdDocument = await createDocumentV2({ - userId: ctx.user.id, - teamId, - documentDataId: documentData.id, - normalizePdf: false, // Not normalizing because of presigned URL. - data: { - title, - externalId, - visibility, - globalAccessAuth, - globalActionAuth, - recipients, - }, - meta, - requestMetadata: ctx.metadata, - }); - - return { - document: createdDocument, - folder: createdDocument.folder, // Todo: Remove this prior to api-v2 release. - uploadUrl: url, - }; - }), - - /** - * Wait until RR7 so we can passthrough documents. - * - * @private - */ - createDocument: authenticatedProcedure - // .meta({ - // openapi: { - // method: 'POST', - // path: '/document/create', - // summary: 'Create document', - // tags: ['Document'], - // }, - // }) - .input(ZCreateDocumentRequestSchema) - .mutation(async ({ input, ctx }) => { - const { user, teamId } = ctx; - const { title, documentDataId, timezone, folderId } = input; - - ctx.logger.info({ - input: { - folderId, - }, - }); - - const { remaining } = await getServerLimits({ userId: user.id, teamId }); - - if (remaining.documents <= 0) { - throw new AppError(AppErrorCode.LIMIT_EXCEEDED, { - message: 'You have reached your document limit for this month. Please upgrade your plan.', - statusCode: 400, - }); - } - - return await createDocument({ - userId: user.id, - teamId, - title, - documentDataId, - normalizePdf: true, - userTimezone: timezone, - requestMetadata: ctx.metadata, - folderId, - }); - }), - - /** - * @public - */ - deleteDocument: authenticatedProcedure - .meta({ - openapi: { - method: 'POST', - path: '/document/delete', - summary: 'Delete document', - tags: ['Document'], - }, - }) - .input(ZDeleteDocumentMutationSchema) - .output(ZSuccessResponseSchema) - .mutation(async ({ input, ctx }) => { - const { teamId } = ctx; - const { documentId } = input; - - ctx.logger.info({ - input: { - documentId, - }, - }); - - const userId = ctx.user.id; - - await deleteDocument({ - id: documentId, - userId, - teamId, - requestMetadata: ctx.metadata, - }); - - return ZGenericSuccessResponse; - }), - - /** - * @private - * - * Todo: Remove and use `updateDocument` endpoint instead. - */ - setSigningOrderForDocument: authenticatedProcedure - .input(ZSetSigningOrderForDocumentMutationSchema) - .mutation(async ({ input, ctx }) => { - const { teamId } = ctx; - const { documentId, signingOrder } = input; - - ctx.logger.info({ - input: { - documentId, - signingOrder, - }, - }); - - return await upsertDocumentMeta({ - userId: ctx.user.id, - teamId, - documentId, - signingOrder, - requestMetadata: ctx.metadata, - }); - }), - - /** - * @public - * - * Todo: Refactor to distributeDocument. - * Todo: Rework before releasing API. - */ - sendDocument: authenticatedProcedure - .meta({ - openapi: { - method: 'POST', - path: '/document/distribute', - summary: 'Distribute document', - description: 'Send the document out to recipients based on your distribution method', - tags: ['Document'], - }, - }) - .input(ZDistributeDocumentRequestSchema) - .output(ZDistributeDocumentResponseSchema) - .mutation(async ({ input, ctx }) => { - const { teamId } = ctx; - const { documentId, meta = {} } = input; - - ctx.logger.info({ - input: { - documentId, - }, - }); - - if (Object.values(meta).length > 0) { - await upsertDocumentMeta({ - userId: ctx.user.id, - teamId, - documentId, - subject: meta.subject, - message: meta.message, - dateFormat: meta.dateFormat, - timezone: meta.timezone, - redirectUrl: meta.redirectUrl, - distributionMethod: meta.distributionMethod, - emailSettings: meta.emailSettings, - language: meta.language, - emailId: meta.emailId, - emailReplyTo: meta.emailReplyTo, - requestMetadata: ctx.metadata, - }); - } - - return await sendDocument({ - userId: ctx.user.id, - documentId, - teamId, - requestMetadata: ctx.metadata, - }); - }), - - /** - * @public - * - * Todo: Refactor to redistributeDocument. - */ - resendDocument: authenticatedProcedure - .meta({ - openapi: { - method: 'POST', - path: '/document/redistribute', - summary: 'Redistribute document', - description: - 'Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document', - tags: ['Document'], - }, - }) - .input(ZResendDocumentMutationSchema) - .output(ZSuccessResponseSchema) - .mutation(async ({ input, ctx }) => { - const { teamId } = ctx; - const { documentId, recipients } = input; - - ctx.logger.info({ - input: { - documentId, - recipients, - }, - }); - - await resendDocument({ - userId: ctx.user.id, - teamId, - documentId, - recipients, - requestMetadata: ctx.metadata, - }); - - return ZGenericSuccessResponse; - }), - - /** - * @public - */ - duplicateDocument: authenticatedProcedure - .meta({ - openapi: { - method: 'POST', - path: '/document/duplicate', - summary: 'Duplicate document', - tags: ['Document'], - }, - }) - .input(ZDuplicateDocumentRequestSchema) - .output(ZDuplicateDocumentResponseSchema) - .mutation(async ({ input, ctx }) => { - const { teamId, user } = ctx; - const { documentId } = input; - - ctx.logger.info({ - input: { - documentId, - }, - }); - - return await duplicateDocument({ - userId: user.id, - teamId, - documentId, - }); - }), - - /** - * @private - */ - searchDocuments: authenticatedProcedure - .input(ZSearchDocumentsMutationSchema) - .query(async ({ input, ctx }) => { - const { query } = input; - - const documents = await searchDocumentsWithKeyword({ - query, - userId: ctx.user.id, - }); - - return documents; - }), - - /** - * @private - */ - findDocumentAuditLogs: authenticatedProcedure - .input(ZFindDocumentAuditLogsQuerySchema) - .query(async ({ input, ctx }) => { - const { teamId } = ctx; - - const { - page, - perPage, - documentId, - cursor, - filterForRecentActivity, - orderByColumn, - orderByDirection, - } = input; - - ctx.logger.info({ - input: { - documentId, - }, - }); - - return await findDocumentAuditLogs({ - userId: ctx.user.id, - teamId, - page, - perPage, - documentId, - cursor, - filterForRecentActivity, - orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, - }); - }), - - /** - * @private - */ - downloadAuditLogs: authenticatedProcedure - .input(ZDownloadAuditLogsMutationSchema) - .mutation(async ({ input, ctx }) => { - const { teamId } = ctx; - const { documentId } = input; - - ctx.logger.info({ - input: { - documentId, - }, - }); - - const document = await getDocumentById({ - documentId, - userId: ctx.user.id, - teamId, - }).catch(() => null); - - if (!document || (teamId && document.teamId !== teamId)) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'You do not have access to this document.', - }); - } - - const encrypted = encryptSecondaryData({ - data: document.id.toString(), - expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), - }); - - return { - url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`, - }; - }), - - /** - * @private - */ - downloadCertificate: authenticatedProcedure - .input(ZDownloadCertificateMutationSchema) - .mutation(async ({ input, ctx }) => { - const { teamId } = ctx; - const { documentId } = input; - - ctx.logger.info({ - input: { - documentId, - }, - }); - - const document = await getDocumentById({ - documentId, - userId: ctx.user.id, - teamId, - }); - - if (!isDocumentCompleted(document.status)) { - throw new AppError('DOCUMENT_NOT_COMPLETE'); - } - - const encrypted = encryptSecondaryData({ - data: document.id.toString(), - expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), - }); - - return { - url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`, - }; - }), + }), }); diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 36eca0e26..f362bf1a1 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -1,39 +1,9 @@ -import { - DocumentDistributionMethod, - DocumentSigningOrder, - DocumentSource, - DocumentStatus, - DocumentVisibility, - FieldType, -} from '@prisma/client'; +import { DocumentDistributionMethod, DocumentVisibility } from '@prisma/client'; import { z } from 'zod'; import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats'; import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n'; -import { - ZDocumentLiteSchema, - ZDocumentManySchema, - ZDocumentSchema, -} from '@documenso/lib/types/document'; -import { - ZDocumentAccessAuthTypesSchema, - ZDocumentActionAuthTypesSchema, -} from '@documenso/lib/types/document-auth'; -import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; -import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values'; -import { - ZFieldHeightSchema, - ZFieldPageNumberSchema, - ZFieldPageXSchema, - ZFieldPageYSchema, - ZFieldWidthSchema, -} from '@documenso/lib/types/field'; -import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; -import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; -import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; - -import { ZCreateRecipientSchema } from '../recipient-router/schema'; /** * Required for empty responses since we currently can't 201 requests for our openapi setup. @@ -116,252 +86,3 @@ export const ZDocumentMetaDrawSignatureEnabledSchema = z export const ZDocumentMetaUploadSignatureEnabledSchema = z .boolean() .describe('Whether to allow recipients to sign using an uploaded signature.'); - -export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({ - templateId: z - .number() - .describe('Filter documents by the template ID used to create it.') - .optional(), - source: z - .nativeEnum(DocumentSource) - .describe('Filter documents by how it was created.') - .optional(), - status: z - .nativeEnum(DocumentStatus) - .describe('Filter documents by the current status') - .optional(), - folderId: z.string().describe('Filter documents by folder ID').optional(), - orderByColumn: z.enum(['createdAt']).optional(), - orderByDirection: z.enum(['asc', 'desc']).describe('').default('desc'), -}); - -export const ZFindDocumentsResponseSchema = ZFindResultResponse.extend({ - data: ZDocumentManySchema.array(), -}); - -export type TFindDocumentsResponse = z.infer; - -export const ZFindDocumentsInternalRequestSchema = ZFindDocumentsRequestSchema.extend({ - period: z.enum(['7d', '14d', '30d']).optional(), - senderIds: z.array(z.number()).optional(), - status: z.nativeEnum(ExtendedDocumentStatus).optional(), - folderId: z.string().optional(), -}); - -export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({ - data: ZDocumentManySchema.array(), - stats: z.object({ - [ExtendedDocumentStatus.DRAFT]: z.number(), - [ExtendedDocumentStatus.PENDING]: z.number(), - [ExtendedDocumentStatus.COMPLETED]: z.number(), - [ExtendedDocumentStatus.REJECTED]: z.number(), - [ExtendedDocumentStatus.INBOX]: z.number(), - [ExtendedDocumentStatus.ALL]: z.number(), - }), -}); - -export type TFindDocumentsInternalResponse = z.infer; - -export const ZFindDocumentAuditLogsQuerySchema = ZFindSearchParamsSchema.extend({ - documentId: z.number().min(1), - cursor: z.string().optional(), - filterForRecentActivity: z.boolean().optional(), - orderByColumn: z.enum(['createdAt', 'type']).optional(), - orderByDirection: z.enum(['asc', 'desc']).default('desc'), -}); - -export const ZGetDocumentByIdQuerySchema = z.object({ - documentId: z.number(), -}); - -export const ZDuplicateDocumentRequestSchema = z.object({ - documentId: z.number(), -}); - -export const ZDuplicateDocumentResponseSchema = z.object({ - documentId: z.number(), -}); - -export const ZGetDocumentByTokenQuerySchema = z.object({ - token: z.string().min(1), -}); - -export type TGetDocumentByTokenQuerySchema = z.infer; - -export const ZGetDocumentWithDetailsByIdRequestSchema = z.object({ - documentId: z.number(), - folderId: z.string().describe('Filter documents by folder ID').optional(), -}); - -export const ZGetDocumentWithDetailsByIdResponseSchema = ZDocumentSchema; - -export const ZCreateDocumentRequestSchema = z.object({ - title: ZDocumentTitleSchema, - documentDataId: z.string().min(1), - timezone: ZDocumentMetaTimezoneSchema.optional(), - folderId: z.string().describe('The ID of the folder to create the document in').optional(), -}); - -export const ZCreateDocumentV2RequestSchema = z.object({ - title: ZDocumentTitleSchema, - externalId: ZDocumentExternalIdSchema.optional(), - visibility: ZDocumentVisibilitySchema.optional(), - globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(), - globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(), - formValues: ZDocumentFormValuesSchema.optional(), - recipients: z - .array( - ZCreateRecipientSchema.extend({ - fields: ZFieldAndMetaSchema.and( - z.object({ - pageNumber: ZFieldPageNumberSchema, - pageX: ZFieldPageXSchema, - pageY: ZFieldPageYSchema, - width: ZFieldWidthSchema, - height: ZFieldHeightSchema, - }), - ) - .array() - .optional(), - }), - ) - .refine( - (recipients) => { - const emails = recipients.map((recipient) => recipient.email); - - return new Set(emails).size === emails.length; - }, - { message: 'Recipients must have unique emails' }, - ) - .optional(), - meta: z - .object({ - subject: ZDocumentMetaSubjectSchema.optional(), - message: ZDocumentMetaMessageSchema.optional(), - timezone: ZDocumentMetaTimezoneSchema.optional(), - dateFormat: ZDocumentMetaDateFormatSchema.optional(), - distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(), - signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), - redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), - language: ZDocumentMetaLanguageSchema.optional(), - typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), - drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(), - uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(), - emailSettings: ZDocumentEmailSettingsSchema.optional(), - }) - .optional(), -}); - -export type TCreateDocumentV2Request = z.infer; - -export const ZCreateDocumentV2ResponseSchema = z.object({ - document: ZDocumentSchema, - uploadUrl: z - .string() - .describe( - 'The URL to upload the document PDF to. Use a PUT request with the file via form-data', - ), -}); - -export const ZSetFieldsForDocumentMutationSchema = z.object({ - documentId: z.number(), - fields: z.array( - z.object({ - id: z.number().nullish(), - type: z.nativeEnum(FieldType), - signerEmail: z.string().min(1), - pageNumber: z.number().min(1), - pageX: z.number().min(0), - pageY: z.number().min(0), - pageWidth: z.number().min(0), - pageHeight: z.number().min(0), - }), - ), -}); - -export type TSetFieldsForDocumentMutationSchema = z.infer< - typeof ZSetFieldsForDocumentMutationSchema ->; - -export const ZDistributeDocumentRequestSchema = z.object({ - documentId: z.number().describe('The ID of the document to send.'), - meta: z - .object({ - subject: ZDocumentMetaSubjectSchema.optional(), - message: ZDocumentMetaMessageSchema.optional(), - timezone: ZDocumentMetaTimezoneSchema.optional(), - dateFormat: ZDocumentMetaDateFormatSchema.optional(), - distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(), - redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), - language: ZDocumentMetaLanguageSchema.optional(), - emailId: z.string().nullish(), - emailReplyTo: z.string().email().nullish(), - emailSettings: ZDocumentEmailSettingsSchema.optional(), - }) - .optional(), -}); - -export const ZDistributeDocumentResponseSchema = ZDocumentLiteSchema; - -export const ZSetPasswordForDocumentMutationSchema = z.object({ - documentId: z.number(), - password: z.string(), -}); - -export type TSetPasswordForDocumentMutationSchema = z.infer< - typeof ZSetPasswordForDocumentMutationSchema ->; - -export const ZSetSigningOrderForDocumentMutationSchema = z.object({ - documentId: z.number(), - signingOrder: z.nativeEnum(DocumentSigningOrder), -}); - -export type TSetSigningOrderForDocumentMutationSchema = z.infer< - typeof ZSetSigningOrderForDocumentMutationSchema ->; - -export const ZResendDocumentMutationSchema = z.object({ - documentId: z.number(), - recipients: z - .array(z.number()) - .min(1) - .describe('The IDs of the recipients to redistribute the document to.'), -}); - -export const ZDeleteDocumentMutationSchema = z.object({ - documentId: z.number(), -}); - -export type TDeleteDocumentMutationSchema = z.infer; - -export const ZSearchDocumentsMutationSchema = z.object({ - query: z.string(), -}); - -export const ZDownloadAuditLogsMutationSchema = z.object({ - documentId: z.number(), -}); - -export const ZDownloadCertificateMutationSchema = z.object({ - documentId: z.number(), -}); - -export const ZDownloadDocumentRequestSchema = z.object({ - documentId: z.number().describe('The ID of the document to download.'), - version: z - .enum(['original', 'signed']) - .describe( - 'The version of the document to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.', - ) - .default('signed'), -}); - -export const ZDownloadDocumentResponseSchema = z.object({ - downloadUrl: z.string().describe('Pre-signed URL for downloading the PDF file'), - filename: z.string().describe('The filename of the PDF file'), - contentType: z.string().describe('MIME type of the file'), -}); - -export type TDownloadDocumentRequest = z.infer; -export type TDownloadDocumentResponse = z.infer; diff --git a/packages/trpc/server/document-router/search-document.ts b/packages/trpc/server/document-router/search-document.ts new file mode 100644 index 000000000..4acd3be69 --- /dev/null +++ b/packages/trpc/server/document-router/search-document.ts @@ -0,0 +1,21 @@ +import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZSearchDocumentRequestSchema, + ZSearchDocumentResponseSchema, +} from './search-document.types'; + +export const searchDocumentRoute = authenticatedProcedure + .input(ZSearchDocumentRequestSchema) + .output(ZSearchDocumentResponseSchema) + .query(async ({ input, ctx }) => { + const { query } = input; + + const documents = await searchDocumentsWithKeyword({ + query, + userId: ctx.user.id, + }); + + return documents; + }); diff --git a/packages/trpc/server/document-router/search-document.types.ts b/packages/trpc/server/document-router/search-document.types.ts new file mode 100644 index 000000000..5a6903066 --- /dev/null +++ b/packages/trpc/server/document-router/search-document.types.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const ZSearchDocumentRequestSchema = z.object({ + query: z.string(), +}); + +export const ZSearchDocumentResponseSchema = z + .object({ + title: z.string(), + path: z.string(), + value: z.string(), + }) + .array(); + +export type TSearchDocumentRequest = z.infer; +export type TSearchDocumentResponse = z.infer; diff --git a/packages/trpc/server/organisation-router/create-organisation-group.ts b/packages/trpc/server/organisation-router/create-organisation-group.ts index b2dc3cde9..db87c8378 100644 --- a/packages/trpc/server/organisation-router/create-organisation-group.ts +++ b/packages/trpc/server/organisation-router/create-organisation-group.ts @@ -40,7 +40,13 @@ export const createOrganisationGroupRoute = authenticatedProcedure groups: true, members: { include: { - user: true, + user: { + select: { + id: true, + email: true, + name: true, + }, + }, }, }, }, diff --git a/packages/trpc/server/organisation-router/update-organisation.ts b/packages/trpc/server/organisation-router/update-organisation.ts index bc71b9de0..73f2445c8 100644 --- a/packages/trpc/server/organisation-router/update-organisation.ts +++ b/packages/trpc/server/organisation-router/update-organisation.ts @@ -1,5 +1,6 @@ import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { stripe } from '@documenso/lib/server-only/stripe'; import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; import { prisma } from '@documenso/prisma'; @@ -38,7 +39,7 @@ export const updateOrganisationRoute = authenticatedProcedure }); } - await prisma.organisation.update({ + const updatedOrganisation = await prisma.organisation.update({ where: { id: organisationId, }, @@ -47,4 +48,12 @@ export const updateOrganisationRoute = authenticatedProcedure url: data.url, }, }); + + if (updatedOrganisation.customerId) { + await stripe.customers.update(updatedOrganisation.customerId, { + metadata: { + organisationName: data.name, + }, + }); + } }); diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 60b7c1323..3f903eb49 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -3,14 +3,12 @@ import type { SetAvatarImageOptions } from '@documenso/lib/server-only/profile/s import { setAvatarImage } from '@documenso/lib/server-only/profile/set-avatar-image'; import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs'; -import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { submitSupportTicket } from '@documenso/lib/server-only/user/submit-support-ticket'; import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; -import { adminProcedure, authenticatedProcedure, router } from '../trpc'; +import { authenticatedProcedure, router } from '../trpc'; import { ZFindUserSecurityAuditLogsSchema, - ZRetrieveUserByIdQuerySchema, ZSetProfileImageMutationSchema, ZSubmitSupportTicketMutationSchema, ZUpdateProfileMutationSchema, @@ -26,24 +24,12 @@ export const profileRouter = router({ }); }), - getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input, ctx }) => { - const { id } = input; - - ctx.logger.info({ - input: { - id, - }, - }); - - return await getUserById({ id }); - }), - updateProfile: authenticatedProcedure .input(ZUpdateProfileMutationSchema) .mutation(async ({ input, ctx }) => { const { name, signature } = input; - return await updateProfile({ + await updateProfile({ userId: ctx.user.id, name, signature, @@ -52,7 +38,7 @@ export const profileRouter = router({ }), deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { - return await deleteUser({ + await deleteUser({ id: ctx.user.id, }); }), diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index e1db5dd78..2d6a85dc3 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -7,12 +7,6 @@ export const ZFindUserSecurityAuditLogsSchema = z.object({ export type TFindUserSecurityAuditLogsSchema = z.infer; -export const ZRetrieveUserByIdQuerySchema = z.object({ - id: z.number().min(1), -}); - -export type TRetrieveUserByIdQuerySchema = z.infer; - export const ZUpdateProfileMutationSchema = z.object({ name: z.string().min(1), signature: z.string(), diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index c52f78223..a9041ad3e 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -339,8 +339,14 @@ export const templateRouter = router({ .output(ZCreateDocumentFromTemplateResponseSchema) .mutation(async ({ ctx, input }) => { const { teamId } = ctx; - const { templateId, recipients, distributeDocument, customDocumentDataId, prefillFields } = - input; + const { + templateId, + recipients, + distributeDocument, + customDocumentDataId, + prefillFields, + folderId, + } = input; ctx.logger.info({ input: { @@ -361,6 +367,7 @@ export const templateRouter = router({ recipients, customDocumentDataId, requestMetadata: ctx.metadata, + folderId, prefillFields, }); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 31284ac58..c1100b99e 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -117,6 +117,12 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({ 'The data ID of an alternative PDF to use when creating the document. If not provided, the PDF attached to the template will be used.', ) .optional(), + folderId: z + .string() + .describe( + 'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.', + ) + .optional(), prefillFields: z .array(ZFieldMetaPrefillFieldsSchema) .describe( diff --git a/packages/ui/components/document/document-read-only-fields.tsx b/packages/ui/components/document/document-read-only-fields.tsx index 0786357d1..d1c96f8a9 100644 --- a/packages/ui/components/document/document-read-only-fields.tsx +++ b/packages/ui/components/document/document-read-only-fields.tsx @@ -95,8 +95,10 @@ export const DocumentReadOnlyFields = ({ setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true })); }; + const highestPageNumber = Math.max(...fields.map((field) => field.page)); + return ( - + {fields.map( (field) => !hiddenFieldIds[field.secondaryId] && ( diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 137516804..820696e0e 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -21,6 +21,7 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { prop, sortBy } from 'remeda'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; +import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { @@ -83,6 +84,7 @@ export type AddFieldsFormProps = { recipients: Recipient[]; fields: Field[]; onSubmit: (_data: TAddFieldsFormSchema) => void; + onAutoSave: (_data: TAddFieldsFormSchema) => Promise; canGoBack?: boolean; isDocumentPdfLoaded: boolean; teamId: number; @@ -94,6 +96,7 @@ export const AddFieldsFormPartial = ({ recipients, fields, onSubmit, + onAutoSave, canGoBack = false, isDocumentPdfLoaded, teamId, @@ -590,6 +593,20 @@ export const AddFieldsFormPartial = ({ } }; + const { scheduleSave } = useAutoSave(onAutoSave); + + const handleAutoSave = async () => { + const isFormValid = await form.trigger(); + + if (!isFormValid) { + return; + } + + const formData = form.getValues(); + + scheduleSave(formData); + }; + return ( <> {showAdvancedSettings && currentField ? ( @@ -603,7 +620,14 @@ export const AddFieldsFormPartial = ({ fields={localFields} onAdvancedSettings={handleAdvancedSettings} isDocumentPdfLoaded={isDocumentPdfLoaded} - onSave={handleSavedFieldSettings} + onSave={(fieldState) => { + handleSavedFieldSettings(fieldState); + void handleAutoSave(); + }} + onAutoSave={async (fieldState) => { + handleSavedFieldSettings(fieldState); + await handleAutoSave(); + }} /> ) : ( <> @@ -660,14 +684,26 @@ export const AddFieldsFormPartial = ({ defaultWidth={DEFAULT_WIDTH_PX} passive={isFieldWithinBounds && !!selectedField} onFocus={() => setLastActiveField(field)} - onBlur={() => setLastActiveField(null)} + onBlur={() => { + setLastActiveField(null); + void handleAutoSave(); + }} onMouseEnter={() => setLastActiveField(field)} onMouseLeave={() => setLastActiveField(null)} onResize={(options) => onFieldResize(options, index)} onMove={(options) => onFieldMove(options, index)} - onRemove={() => remove(index)} - onDuplicate={() => onFieldCopy(null, { duplicate: true })} - onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })} + onRemove={() => { + remove(index); + void handleAutoSave(); + }} + onDuplicate={() => { + onFieldCopy(null, { duplicate: true }); + void handleAutoSave(); + }} + onDuplicateAllPages={() => { + onFieldCopy(null, { duplicateAll: true }); + void handleAutoSave(); + }} onAdvancedSettings={() => { setCurrentField(field); handleAdvancedSettings(); diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index 3c06f9d1c..3d1789e31 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -14,6 +14,7 @@ import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; +import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document'; @@ -79,6 +80,7 @@ export type AddSettingsFormProps = { document: TDocument; currentTeamMemberRole?: TeamMemberRole; onSubmit: (_data: TAddSettingsFormSchema) => void; + onAutoSave: (_data: TAddSettingsFormSchema) => Promise; }; export const AddSettingsFormPartial = ({ @@ -89,6 +91,7 @@ export const AddSettingsFormPartial = ({ document, currentTeamMemberRole, onSubmit, + onAutoSave, }: AddSettingsFormProps) => { const { t } = useLingui(); @@ -161,6 +164,28 @@ export const AddSettingsFormPartial = ({ document.documentMeta?.timezone, ]); + const { scheduleSave } = useAutoSave(onAutoSave); + + const handleAutoSave = async () => { + const isFormValid = await form.trigger(); + + if (!isFormValid) { + return; + } + + const formData = form.getValues(); + + /* + * Parse the form data through the Zod schema to handle transformations + * (like -1 -> undefined for the Document Global Auth Access) + */ + const parseResult = ZAddSettingsFormSchema.safeParse(formData); + + if (parseResult.success) { + scheduleSave(parseResult.data); + } + }; + return ( <> @@ -227,9 +253,13 @@ export const AddSettingsFormPartial = ({ + @@ -372,7 +413,10 @@ export const AddSettingsFormPartial = ({ value: option.value, }))} selectedValues={field.value} - onChange={field.onChange} + onChange={(value) => { + field.onChange(value); + void handleAutoSave(); + }} className="bg-background w-full" emptySelectionPlaceholder="Select signature types" /> @@ -394,8 +438,12 @@ export const AddSettingsFormPartial = ({ + diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 6b280f90f..a57c87167 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -14,6 +14,7 @@ import { useFieldArray, useForm } from 'react-hook-form'; import { prop, sortBy } from 'remeda'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; @@ -55,6 +56,7 @@ export type AddSignersFormProps = { signingOrder?: DocumentSigningOrder | null; allowDictateNextSigner?: boolean; onSubmit: (_data: TAddSignersFormSchema) => void; + onAutoSave: (_data: TAddSignersFormSchema) => Promise; isDocumentPdfLoaded: boolean; }; @@ -65,6 +67,7 @@ export const AddSignersFormPartial = ({ signingOrder, allowDictateNextSigner, onSubmit, + onAutoSave, isDocumentPdfLoaded, }: AddSignersFormProps) => { const { _ } = useLingui(); @@ -166,6 +169,29 @@ export const AddSignersFormPartial = ({ name: 'signers', }); + const emptySigners = useCallback( + () => form.getValues('signers').filter((signer) => signer.email === ''), + [form], + ); + + const { scheduleSave } = useAutoSave(onAutoSave); + + const handleAutoSave = async () => { + if (emptySigners().length > 0) { + return; + } + + const isFormValid = await form.trigger(); + + if (!isFormValid) { + return; + } + + const formData = form.getValues(); + + scheduleSave(formData); + }; + const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email); const isUserAlreadyARecipient = watchedSigners.some( (signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(), @@ -216,24 +242,47 @@ export const AddSignersFormPartial = ({ const formStateIndex = form.getValues('signers').findIndex((s) => s.formId === signer.formId); if (formStateIndex !== -1) { removeSigner(formStateIndex); + const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId); - form.setValue('signers', normalizeSigningOrders(updatedSigners)); + + form.setValue('signers', normalizeSigningOrders(updatedSigners), { + shouldValidate: true, + shouldDirty: true, + }); + + void handleAutoSave(); } }; const onAddSelfSigner = () => { 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: [], - signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, + setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', { + shouldValidate: true, + shouldDirty: true, }); + setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '', { + shouldValidate: true, + shouldDirty: true, + }); + + form.setFocus(`signers.${emptySignerIndex}.email`); + } else { + appendSigner( + { + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: [], + signingOrder: + signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, + }, + { + shouldFocus: true, + }, + ); + + void form.trigger('signers'); } }; @@ -263,7 +312,10 @@ export const AddSignersFormPartial = ({ signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1, })); - form.setValue('signers', updatedSigners); + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); const lastSigner = updatedSigners[updatedSigners.length - 1]; if (lastSigner.role === RecipientRole.ASSISTANT) { @@ -276,8 +328,10 @@ export const AddSignersFormPartial = ({ } await form.trigger('signers'); + + void handleAutoSave(); }, - [form, canRecipientBeModified, watchedSigners, toast], + [form, canRecipientBeModified, watchedSigners, handleAutoSave, toast], ); const handleRoleChange = useCallback( @@ -287,7 +341,10 @@ export const AddSignersFormPartial = ({ // Handle parallel to sequential conversion for assistants if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { - form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL); + form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL, { + shouldValidate: true, + shouldDirty: true, + }); toast({ title: _(msg`Signing order is enabled.`), description: _(msg`You cannot add assistants when signing order is disabled.`), @@ -302,7 +359,10 @@ export const AddSignersFormPartial = ({ signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1, })); - form.setValue('signers', updatedSigners); + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { toast({ @@ -341,7 +401,10 @@ export const AddSignersFormPartial = ({ signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1, })); - form.setValue('signers', updatedSigners); + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) { toast({ @@ -364,9 +427,20 @@ export const AddSignersFormPartial = ({ role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, })); - form.setValue('signers', updatedSigners); - form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); - form.setValue('allowDictateNextSigner', false); + form.setValue('signers', updatedSigners, { + shouldValidate: true, + shouldDirty: true, + }); + form.setValue('signingOrder', DocumentSigningOrder.PARALLEL, { + shouldValidate: true, + shouldDirty: true, + }); + form.setValue('allowDictateNextSigner', false, { + shouldValidate: true, + shouldDirty: true, + }); + + void handleAutoSave(); }, [form]); return ( @@ -408,19 +482,39 @@ export const AddSignersFormPartial = ({ // If sequential signing is turned off, disable dictate next signer if (!checked) { - form.setValue('allowDictateNextSigner', false); + form.setValue('allowDictateNextSigner', false, { + shouldValidate: true, + shouldDirty: true, + }); } + + void handleAutoSave(); }} - disabled={isSubmitting || hasDocumentBeenSent} + disabled={isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0} /> - - Enable signing order - +
+ + Enable signing order + + + + + + + + + +

+ Add 2 or more signers to enable signing order. +

+
+
+
)} /> @@ -435,12 +529,15 @@ export const AddSignersFormPartial = ({ {...field} id="allowDictateNextSigner" checked={value} - onCheckedChange={field.onChange} + onCheckedChange={(checked) => { + field.onChange(checked); + void handleAutoSave(); + }} disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential} /> -
+
{ field.onChange(e); handleSigningOrderChange(index, e.target.value); + void handleAutoSave(); }} onBlur={(e) => { field.onBlur(); handleSigningOrderChange(index, e.target.value); + void handleAutoSave(); }} disabled={ snapshot.isDragging || @@ -588,7 +688,9 @@ export const AddSignersFormPartial = ({ isSubmitting || !canRecipientBeModified(signer.nativeId) } + data-testid="signer-email-input" onKeyDown={onKeyDown} + onBlur={handleAutoSave} /> @@ -626,6 +728,7 @@ export const AddSignersFormPartial = ({ !canRecipientBeModified(signer.nativeId) } onKeyDown={onKeyDown} + onBlur={handleAutoSave} /> @@ -668,6 +771,7 @@ export const AddSignersFormPartial = ({
( + onValueChange={(value) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - handleRoleChange(index, value as RecipientRole) - } + handleRoleChange(index, value as RecipientRole); + void handleAutoSave(); + }} disabled={ snapshot.isDragging || isSubmitting || @@ -706,6 +811,7 @@ export const AddSignersFormPartial = ({ 'mb-6': form.formState.errors.signers?.[index], }, )} + data-testid="remove-signer-button" disabled={ snapshot.isDragging || isSubmitting || diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index 6553597f2..82f6f11d5 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -1,3 +1,5 @@ +import { useEffect } from 'react'; + import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; @@ -8,6 +10,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; +import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { TDocument } from '@documenso/lib/types/document'; @@ -60,6 +63,7 @@ export type AddSubjectFormProps = { fields: Field[]; document: TDocument; onSubmit: (_data: TAddSubjectFormSchema) => void; + onAutoSave: (_data: TAddSubjectFormSchema) => Promise; isDocumentPdfLoaded: boolean; }; @@ -69,6 +73,7 @@ export const AddSubjectFormPartial = ({ fields: fields, document, onSubmit, + onAutoSave, isDocumentPdfLoaded, }: AddSubjectFormProps) => { const { _ } = useLingui(); @@ -95,6 +100,8 @@ export const AddSubjectFormPartial = ({ handleSubmit, setValue, watch, + trigger, + getValues, formState: { isSubmitting }, } = form; @@ -129,6 +136,35 @@ export const AddSubjectFormPartial = ({ const onFormSubmit = handleSubmit(onSubmit); const { currentStep, totalSteps, previousStep } = useStep(); + const { scheduleSave } = useAutoSave(onAutoSave); + + const handleAutoSave = async () => { + const isFormValid = await trigger(); + + if (!isFormValid) { + return; + } + + const formData = getValues(); + + scheduleSave(formData); + }; + + useEffect(() => { + const container = window.document.getElementById('document-flow-form-container'); + + const handleBlur = () => { + void handleAutoSave(); + }; + + if (container) { + container.addEventListener('blur', handleBlur, true); + return () => { + container.removeEventListener('blur', handleBlur, true); + }; + } + }, []); + return ( <> Email Sender - @@ -592,6 +663,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ signers[index].email === user?.email || isSignerDirectRecipient(signer) } + onBlur={handleAutoSave} + data-testid="placeholder-recipient-name-input" /> @@ -633,10 +706,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ + onValueChange={(value) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - handleRoleChange(index, value as RecipientRole) - } + handleRoleChange(index, value as RecipientRole); + }} disabled={isSubmitting} hideCCRecipients={isSignerDirectRecipient(signer)} /> @@ -672,6 +745,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50" disabled={isSubmitting || signers.length === 1} onClick={() => onRemoveSigner(index)} + data-testid="remove-placeholder-recipient-button" > diff --git a/packages/ui/primitives/template-flow/add-template-settings.tsx b/packages/ui/primitives/template-flow/add-template-settings.tsx index d880b8edb..374e31a69 100644 --- a/packages/ui/primitives/template-flow/add-template-settings.tsx +++ b/packages/ui/primitives/template-flow/add-template-settings.tsx @@ -9,6 +9,7 @@ import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; +import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { @@ -83,6 +84,7 @@ export type AddTemplateSettingsFormProps = { template: TTemplate; currentTeamMemberRole?: TeamMemberRole; onSubmit: (_data: TAddTemplateSettingsFormSchema) => void; + onAutoSave: (_data: TAddTemplateSettingsFormSchema) => Promise; }; export const AddTemplateSettingsFormPartial = ({ @@ -93,6 +95,7 @@ export const AddTemplateSettingsFormPartial = ({ template, currentTeamMemberRole, onSubmit, + onAutoSave, }: AddTemplateSettingsFormProps) => { const { t, i18n } = useLingui(); @@ -160,6 +163,28 @@ export const AddTemplateSettingsFormPartial = ({ } }, [form, form.setValue, form.formState.touchedFields.meta?.timezone]); + const { scheduleSave } = useAutoSave(onAutoSave); + + const handleAutoSave = async () => { + const isFormValid = await form.trigger(); + + if (!isFormValid) { + return; + } + + const formData = form.getValues(); + + /* + * Parse the form data through the Zod schema to handle transformations + * (like -1 -> undefined for the Document Global Auth Access) + */ + const parseResult = ZAddTemplateSettingsFormSchema.safeParse(formData); + + if (parseResult.success) { + scheduleSave(parseResult.data); + } + }; + return ( <> - + @@ -219,7 +244,13 @@ export const AddTemplateSettingsFormPartial = ({ - { + field.onChange(value); + void handleAutoSave(); + }} + > @@ -250,9 +281,13 @@ export const AddTemplateSettingsFormPartial = ({ { + field.onChange(value); + void handleAutoSave(); + }} value={field.value} disabled={field.disabled} - onValueChange={field.onChange} /> @@ -275,7 +310,10 @@ export const AddTemplateSettingsFormPartial = ({ canUpdateVisibility={canUpdateVisibility} currentTeamMemberRole={currentTeamMemberRole} {...field} - onValueChange={field.onChange} + onValueChange={(value) => { + field.onChange(value); + void handleAutoSave(); + }} /> @@ -334,7 +372,13 @@ export const AddTemplateSettingsFormPartial = ({ - { + field.onChange(value); + void handleAutoSave(); + }} + > @@ -371,7 +415,10 @@ export const AddTemplateSettingsFormPartial = ({ value: option.value, }))} selectedValues={field.value} - onChange={field.onChange} + onChange={(value) => { + field.onChange(value); + void handleAutoSave(); + }} className="bg-background w-full" emptySelectionPlaceholder="Select signature types" /> @@ -395,9 +442,13 @@ export const AddTemplateSettingsFormPartial = ({ { + field.onChange(value); + void handleAutoSave(); + }} value={field.value} disabled={field.disabled} - onValueChange={field.onChange} /> @@ -488,7 +539,7 @@ export const AddTemplateSettingsFormPartial = ({ - + @@ -515,7 +566,11 @@ export const AddTemplateSettingsFormPartial = ({ -