From adefac81e269e5296ed2d63f7d7e40b7191ca3a6 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Sun, 24 Aug 2025 06:48:30 +0000 Subject: [PATCH 01/47] fix: outdated docs (#1985) --- README.md | 4 +--- SIGNING.md | 2 +- .../developers/contributing/contributing-translations.mdx | 2 +- apps/documentation/pages/developers/self-hosting/how-to.mdx | 6 +++--- docker/testing/compose.yml | 2 +- render.yaml | 2 +- 6 files changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index aa423aa2b..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 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/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/render.yaml b/render.yaml index 07a90f0b1..a3d7d2a8f 100644 --- a/render.yaml +++ b/render.yaml @@ -4,7 +4,7 @@ services: name: documenso-app plan: free buildCommand: npm i && npm run build - startCommand: npx prisma migrate deploy --schema packages/prisma/schema.prisma && npx turbo run start --filter=@documenso/web + startCommand: npx prisma migrate deploy --schema packages/prisma/schema.prisma && npx turbo run start --filter=@documenso/remix healthCheckPath: /api/health envVars: From d7e5a9eec7cacb307817d609ddeece0838d0c3ec Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 25 Aug 2025 08:23:12 +1000 Subject: [PATCH 02/47] fix: refactor document router (#1990) --- .../dialogs/document-delete-dialog.tsx | 2 +- .../dialogs/document-duplicate-dialog.tsx | 5 +- .../dialogs/document-resend-dialog.tsx | 2 +- .../components/general/app-command-menu.tsx | 2 +- .../document-audit-log-download-button.tsx | 2 +- .../document/document-drop-zone-wrapper.tsx | 2 +- .../general/document/document-edit-form.tsx | 50 +- .../document/document-page-view-button.tsx | 2 +- .../document/document-page-view-dropdown.tsx | 4 +- .../document-page-view-recent-activity.tsx | 2 +- .../general/document/document-upload.tsx | 2 +- .../general/legacy-field-warning-popover.tsx | 2 +- .../template-page-view-documents-table.tsx | 2 +- .../template-page-view-recent-activity.tsx | 2 +- .../components/tables/document-logs-table.tsx | 2 +- .../tables/documents-table-action-button.tsx | 2 +- .../documents-table-action-dropdown.tsx | 4 +- .../app/components/tables/documents-table.tsx | 2 +- .../app/components/tables/inbox-table.tsx | 5 +- .../t.$teamUrl+/documents._index.tsx | 6 +- .../lib/utils/handle-oauth-callback-url.ts | 6 +- .../send-document-cancelled-emails.handler.ts | 8 +- ...rganisation-member-joined-email.handler.ts | 16 +- ...-organisation-member-left-email.handler.ts | 13 +- .../send-recipient-signed-email.handler.ts | 8 +- .../emails/send-rejection-emails.handler.ts | 8 +- .../document/create-document-v2.ts | 4 +- .../server-only/document/find-documents.ts | 7 +- .../document/get-document-by-token.ts | 17 +- .../get-document-with-details-by-id.ts | 3 - .../document/reject-document-with-token.ts | 8 +- .../document/send-completed-email.ts | 8 +- .../server-only/document/send-delete-email.ts | 8 +- .../document/super-delete-document.ts | 8 +- .../create-document-from-direct-template.ts | 8 +- .../lib/server-only/user/reset-password.ts | 9 +- packages/lib/server-only/user/verify-email.ts | 8 +- .../mask-recipient-tokens-for-document.ts | 2 +- .../create-document-temporary.ts | 81 ++ .../create-document-temporary.types.ts | 120 +++ .../server/document-router/create-document.ts | 47 ++ .../document-router/create-document.types.ts | 27 + .../server/document-router/delete-document.ts | 35 + .../document-router/delete-document.types.ts | 22 + .../document-router/distribute-document.ts | 50 ++ .../distribute-document.types.ts | 48 ++ .../download-document-audit-logs.ts | 47 ++ .../download-document-audit-logs.types.ts | 16 + .../download-document-certificate.ts | 46 ++ .../download-document-certificate.types.ts | 16 + .../document-router/download-document.ts | 16 +- .../download-document.types.ts | 32 + .../document-router/duplicate-document.ts | 29 + .../duplicate-document.types.ts | 23 + .../find-document-audit-logs.ts | 41 + .../find-document-audit-logs.types.ts | 20 + .../find-documents-internal.ts | 74 ++ .../find-documents-internal.types.ts | 29 + .../server/document-router/find-documents.ts | 43 ++ .../document-router/find-documents.types.ts | 42 + .../document-router/get-document-by-token.ts | 43 ++ .../get-document-by-token.types.ts | 14 + .../server/document-router/get-document.ts | 29 + .../document-router/get-document.types.ts | 24 + .../document-router/redistribute-document.ts | 35 + .../redistribute-document.types.ts | 28 + .../trpc/server/document-router/router.ts | 722 +----------------- .../trpc/server/document-router/schema.ts | 287 +------ .../server/document-router/search-document.ts | 21 + .../document-router/search-document.types.ts | 16 + .../create-organisation-group.ts | 8 +- 71 files changed, 1310 insertions(+), 1072 deletions(-) create mode 100644 packages/trpc/server/document-router/create-document-temporary.ts create mode 100644 packages/trpc/server/document-router/create-document-temporary.types.ts create mode 100644 packages/trpc/server/document-router/create-document.ts create mode 100644 packages/trpc/server/document-router/create-document.types.ts create mode 100644 packages/trpc/server/document-router/delete-document.ts create mode 100644 packages/trpc/server/document-router/delete-document.types.ts create mode 100644 packages/trpc/server/document-router/distribute-document.ts create mode 100644 packages/trpc/server/document-router/distribute-document.types.ts create mode 100644 packages/trpc/server/document-router/download-document-audit-logs.ts create mode 100644 packages/trpc/server/document-router/download-document-audit-logs.types.ts create mode 100644 packages/trpc/server/document-router/download-document-certificate.ts create mode 100644 packages/trpc/server/document-router/download-document-certificate.types.ts create mode 100644 packages/trpc/server/document-router/download-document.types.ts create mode 100644 packages/trpc/server/document-router/duplicate-document.ts create mode 100644 packages/trpc/server/document-router/duplicate-document.types.ts create mode 100644 packages/trpc/server/document-router/find-document-audit-logs.ts create mode 100644 packages/trpc/server/document-router/find-document-audit-logs.types.ts create mode 100644 packages/trpc/server/document-router/find-documents-internal.ts create mode 100644 packages/trpc/server/document-router/find-documents-internal.types.ts create mode 100644 packages/trpc/server/document-router/find-documents.ts create mode 100644 packages/trpc/server/document-router/find-documents.types.ts create mode 100644 packages/trpc/server/document-router/get-document-by-token.ts create mode 100644 packages/trpc/server/document-router/get-document-by-token.types.ts create mode 100644 packages/trpc/server/document-router/get-document.ts create mode 100644 packages/trpc/server/document-router/get-document.types.ts create mode 100644 packages/trpc/server/document-router/redistribute-document.ts create mode 100644 packages/trpc/server/document-router/redistribute-document.types.ts create mode 100644 packages/trpc/server/document-router/search-document.ts create mode 100644 packages/trpc/server/document-router/search-document.types.ts 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/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/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..df73d92b6 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, }, @@ -216,15 +202,11 @@ export const DocumentEditForm = ({ const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { try { await Promise.all([ - setSigningOrderForDocument({ - documentId: document.id, - signingOrder: data.signingOrder, - }), - updateDocument({ documentId: document.id, meta: { allowDictateNextSigner: data.allowDictateNextSigner, + signingOrder: data.signingOrder, }, }), 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-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/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/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/packages/auth/server/lib/utils/handle-oauth-callback-url.ts b/packages/auth/server/lib/utils/handle-oauth-callback-url.ts index 76ec6a607..96967cc4e 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, + }, + }, }, }); 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/server-only/document/create-document-v2.ts b/packages/lib/server-only/document/create-document-v2.ts index 4a23d67c7..4fe6103d8 100644 --- a/packages/lib/server-only/document/create-document-v2.ts +++ b/packages/lib/server-only/document/create-document-v2.ts @@ -15,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'; @@ -45,7 +45,7 @@ export type CreateDocumentOptions = { globalAccessAuth?: TDocumentAccessAuthTypes[]; globalActionAuth?: TDocumentActionAuthTypes[]; formValues?: TDocumentFormValues; - recipients: TCreateDocumentV2Request['recipients']; + recipients: TCreateDocumentTemporaryRequest['recipients']; folderId?: string; }; meta?: Partial>; 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/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/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/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/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/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 8d3cc8020..da5b8e769 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -1,691 +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, - 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, - }; - }), - - /** - * 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 22425a2f4..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,258 +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(), - 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 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, + }, + }, }, }, }, From 5a5bfe6e3427c19db8b4888d6a93a6ef8333a225 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 25 Aug 2025 08:23:48 +1000 Subject: [PATCH 03/47] fix: refactor admin router (#1982) --- .../dialogs/admin-document-delete-dialog.tsx | 2 +- .../dialogs/admin-user-delete-dialog.tsx | 2 +- .../dialogs/admin-user-disable-dialog.tsx | 2 +- .../dialogs/admin-user-enable-dialog.tsx | 2 +- .../admin-document-recipient-item-table.tsx | 2 +- .../_authenticated+/admin+/documents.$id.tsx | 2 +- .../admin+/documents._index.tsx | 2 +- .../_authenticated+/admin+/users.$id.tsx | 6 +- packages/lib/server-only/admin/update-user.ts | 2 +- .../server/admin-router/delete-document.ts | 28 +++ .../admin-router/delete-document.types.ts | 11 + .../trpc/server/admin-router/delete-user.ts | 19 ++ .../server/admin-router/delete-user.types.ts | 10 + .../trpc/server/admin-router/disable-user.ts | 29 +++ .../server/admin-router/disable-user.types.ts | 10 + .../trpc/server/admin-router/enable-user.ts | 29 +++ .../server/admin-router/enable-user.types.ts | 10 + .../server/admin-router/find-documents.ts | 13 ++ .../admin-router/find-documents.types.ts | 17 ++ .../server/admin-router/reseal-document.ts | 28 +++ .../admin-router/reseal-document.types.ts | 10 + packages/trpc/server/admin-router/router.ts | 200 ++---------------- packages/trpc/server/admin-router/schema.ts | 67 ------ .../server/admin-router/update-recipient.ts | 22 ++ .../admin-router/update-recipient.types.ts | 12 ++ .../admin-router/update-site-setting.ts | 27 +++ .../admin-router/update-site-setting.types.ts | 10 + .../trpc/server/admin-router/update-user.ts | 20 ++ .../server/admin-router/update-user.types.ts | 14 ++ 29 files changed, 353 insertions(+), 255 deletions(-) create mode 100644 packages/trpc/server/admin-router/delete-document.ts create mode 100644 packages/trpc/server/admin-router/delete-document.types.ts create mode 100644 packages/trpc/server/admin-router/delete-user.ts create mode 100644 packages/trpc/server/admin-router/delete-user.types.ts create mode 100644 packages/trpc/server/admin-router/disable-user.ts create mode 100644 packages/trpc/server/admin-router/disable-user.types.ts create mode 100644 packages/trpc/server/admin-router/enable-user.ts create mode 100644 packages/trpc/server/admin-router/enable-user.types.ts create mode 100644 packages/trpc/server/admin-router/find-documents.ts create mode 100644 packages/trpc/server/admin-router/find-documents.types.ts create mode 100644 packages/trpc/server/admin-router/reseal-document.ts create mode 100644 packages/trpc/server/admin-router/reseal-document.types.ts delete mode 100644 packages/trpc/server/admin-router/schema.ts create mode 100644 packages/trpc/server/admin-router/update-recipient.ts create mode 100644 packages/trpc/server/admin-router/update-recipient.types.ts create mode 100644 packages/trpc/server/admin-router/update-site-setting.ts create mode 100644 packages/trpc/server/admin-router/update-site-setting.types.ts create mode 100644 packages/trpc/server/admin-router/update-user.ts create mode 100644 packages/trpc/server/admin-router/update-user.types.ts 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..d2f0cf8b7 100644 --- a/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx @@ -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..5d995ccdf 100644 --- a/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx @@ -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..702b2d73b 100644 --- a/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx +++ b/apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -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/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/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 35640b28e..27b7509f2 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..9242d3dd5 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx @@ -9,7 +9,7 @@ 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 { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types'; import { Button } from '@documenso/ui/primitives/button'; import { Form, @@ -33,7 +33,7 @@ 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; @@ -85,7 +85,7 @@ const AdminUserPage = ({ user }: { user: User }) => { 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/packages/lib/server-only/admin/update-user.ts b/packages/lib/server-only/admin/update-user.ts index 6ee176803..c600fe863 100644 --- a/packages/lib/server-only/admin/update-user.ts +++ b/packages/lib/server-only/admin/update-user.ts @@ -16,7 +16,7 @@ export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) }, }); - return await prisma.user.update({ + await prisma.user.update({ where: { id, }, 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/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..7c6f1558b 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -1,40 +1,23 @@ -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 { 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 +36,19 @@ export const adminRouter = router({ createCustomer: createStripeCustomerRoute, }, user: { + 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; From 49fabeb0ec47e1f3dc6c5dc7d2bae3f0ab0b0a5d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 25 Aug 2025 08:24:32 +1000 Subject: [PATCH 04/47] fix: refactor auth router (#1983) --- .../dialogs/passkey-create-dialog.tsx | 4 +- apps/remix/app/components/forms/signin.tsx | 2 +- .../document-signing-auth-passkey.tsx | 2 +- .../document-signing-auth-provider.tsx | 2 +- ...ettings-security-passkey-table-actions.tsx | 4 +- .../settings-security-passkey-table.tsx | 2 +- .../create-passkey-authentication-options.ts | 17 +++ ...te-passkey-authentication-options.types.ts | 19 +++ .../create-passkey-registration-options.ts | 16 +++ ...eate-passkey-registration-options.types.ts | 12 ++ .../create-passkey-signin-options.ts | 24 ++++ .../create-passkey-signin-options.types.ts | 15 +++ .../trpc/server/auth-router/create-passkey.ts | 21 +++ .../auth-router/create-passkey.types.ts | 13 ++ .../trpc/server/auth-router/delete-passkey.ts | 23 ++++ .../auth-router/delete-passkey.types.ts | 10 ++ .../trpc/server/auth-router/find-passkeys.ts | 18 +++ .../server/auth-router/find-passkeys.types.ts | 33 +++++ packages/trpc/server/auth-router/router.ts | 125 +++--------------- packages/trpc/server/auth-router/schema.ts | 50 ------- .../trpc/server/auth-router/update-passkey.ts | 24 ++++ .../auth-router/update-passkey.types.ts | 11 ++ 22 files changed, 280 insertions(+), 167 deletions(-) create mode 100644 packages/trpc/server/auth-router/create-passkey-authentication-options.ts create mode 100644 packages/trpc/server/auth-router/create-passkey-authentication-options.types.ts create mode 100644 packages/trpc/server/auth-router/create-passkey-registration-options.ts create mode 100644 packages/trpc/server/auth-router/create-passkey-registration-options.types.ts create mode 100644 packages/trpc/server/auth-router/create-passkey-signin-options.ts create mode 100644 packages/trpc/server/auth-router/create-passkey-signin-options.types.ts create mode 100644 packages/trpc/server/auth-router/create-passkey.ts create mode 100644 packages/trpc/server/auth-router/create-passkey.types.ts create mode 100644 packages/trpc/server/auth-router/delete-passkey.ts create mode 100644 packages/trpc/server/auth-router/delete-passkey.types.ts create mode 100644 packages/trpc/server/auth-router/find-passkeys.ts create mode 100644 packages/trpc/server/auth-router/find-passkeys.types.ts create mode 100644 packages/trpc/server/auth-router/update-passkey.ts create mode 100644 packages/trpc/server/auth-router/update-passkey.types.ts 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/forms/signin.tsx b/apps/remix/app/components/forms/signin.tsx index 2d7f9dffe..a9b7000ea 100644 --- a/apps/remix/app/components/forms/signin.tsx +++ b/apps/remix/app/components/forms/signin.tsx @@ -114,7 +114,7 @@ export const SignInForm = ({ }, [returnTo]); const { mutateAsync: createPasskeySigninOptions } = - trpc.auth.createPasskeySigninOptions.useMutation(); + trpc.auth.passkey.createSigninOptions.useMutation(); const form = useForm({ values: { 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/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/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; From b8d07fd1a640de56010ed9f0afd59a8f473f4929 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 25 Aug 2025 08:25:01 +1000 Subject: [PATCH 05/47] fix: refactor token router (#1981) --- .../dialogs/token-delete-dialog.tsx | 2 +- apps/remix/app/components/forms/token.tsx | 6 +-- .../t.$teamUrl+/settings.tokens.tsx | 2 +- .../public-api/delete-api-token-by-id.ts | 2 +- .../api-token-router/create-api-token.ts | 27 ++++++++++ .../create-api-token.types.ts | 12 +++++ .../api-token-router/delete-api-token.ts | 27 ++++++++++ .../delete-api-token.types.ts | 8 +++ .../server/api-token-router/get-api-tokens.ts | 19 +++++++ .../api-token-router/get-api-tokens.types.ts | 16 ++++++ .../trpc/server/api-token-router/router.ts | 54 +++---------------- .../trpc/server/api-token-router/schema.ts | 16 ------ 12 files changed, 122 insertions(+), 69 deletions(-) create mode 100644 packages/trpc/server/api-token-router/create-api-token.ts create mode 100644 packages/trpc/server/api-token-router/create-api-token.types.ts create mode 100644 packages/trpc/server/api-token-router/delete-api-token.ts create mode 100644 packages/trpc/server/api-token-router/delete-api-token.types.ts create mode 100644 packages/trpc/server/api-token-router/get-api-tokens.ts create mode 100644 packages/trpc/server/api-token-router/get-api-tokens.types.ts delete mode 100644 packages/trpc/server/api-token-router/schema.ts 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/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/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/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/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; From fe4d3ed1fdc702ec2629dc1092c194f434a3f619 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 25 Aug 2025 09:48:04 +1000 Subject: [PATCH 06/47] v1.12.2-rc.5 --- apps/remix/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/remix/package.json b/apps/remix/package.json index a0c20d515..728f4ec26 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.5" } diff --git a/package-lock.json b/package-lock.json index 7b2d0ed69..28385e051 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.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "1.12.2-rc.4", + "version": "1.12.2-rc.5", "workspaces": [ "apps/*", "packages/*" @@ -89,7 +89,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "1.12.2-rc.4", + "version": "1.12.2-rc.5", "dependencies": { "@documenso/api": "*", "@documenso/assets": "*", diff --git a/package.json b/package.json index 1f1a39496..86efef735 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "1.12.2-rc.4", + "version": "1.12.2-rc.5", "scripts": { "build": "turbo run build", "dev": "turbo run dev --filter=@documenso/remix", From dbf10e5b7b30c8bbbedca026d51752e211b339fd Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 25 Aug 2025 11:32:15 +1000 Subject: [PATCH 07/47] chore: add agents file (#1991) --- AGENTS.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 AGENTS.md 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 From 7eb882aea8ff51f3f75ff44dac6b378a6ca3e27e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 25 Aug 2025 20:59:37 +1000 Subject: [PATCH 08/47] fix: email domain sender logic (#1993) --- packages/lib/server-only/email/get-email-context.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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); From 44f5da95b3a4eae28f635ab3bf25f37ee0a51df1 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 25 Aug 2025 21:00:35 +1000 Subject: [PATCH 09/47] chore: refactor routes (#1992) --- .../dialogs/admin-user-delete-dialog.tsx | 4 ++-- .../dialogs/admin-user-disable-dialog.tsx | 4 ++-- .../dialogs/admin-user-enable-dialog.tsx | 4 ++-- .../admin-user-reset-two-factor-dialog.tsx | 4 ++-- .../_authenticated+/admin+/users.$id.tsx | 6 +++--- .../organisation.invite.$token.tsx | 3 +++ .../lib/utils/handle-oauth-callback-url.ts | 4 ++++ .../emails/send-signing-email.handler.ts | 5 +++++ packages/lib/server-only/admin/update-user.ts | 6 ------ .../lib/server-only/user/get-user-by-id.ts | 20 +++++++++++++++++- .../lib/server-only/user/update-profile.ts | 4 ++-- packages/trpc/server/admin-router/get-user.ts | 19 +++++++++++++++++ .../server/admin-router/get-user.types.ts | 21 +++++++++++++++++++ packages/trpc/server/admin-router/router.ts | 2 ++ packages/trpc/server/profile-router/router.ts | 20 +++--------------- packages/trpc/server/profile-router/schema.ts | 6 ------ 16 files changed, 89 insertions(+), 43 deletions(-) create mode 100644 packages/trpc/server/admin-router/get-user.ts create mode 100644 packages/trpc/server/admin-router/get-user.types.ts 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 d2f0cf8b7..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) => { 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 5d995ccdf..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 = ({ 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 702b2d73b..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) => { 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/routes/_authenticated+/admin+/users.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx index 9242d3dd5..3cd0f5853 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx @@ -2,13 +2,13 @@ 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 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 { @@ -38,7 +38,7 @@ 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,7 +78,7 @@ 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(); 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/packages/auth/server/lib/utils/handle-oauth-callback-url.ts b/packages/auth/server/lib/utils/handle-oauth-callback-url.ts index 96967cc4e..15fc1f7fa 100644 --- a/packages/auth/server/lib/utils/handle-oauth-callback-url.ts +++ b/packages/auth/server/lib/utils/handle-oauth-callback-url.ts @@ -111,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/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 c600fe863..cc9dcb80d 100644 --- a/packages/lib/server-only/admin/update-user.ts +++ b/packages/lib/server-only/admin/update-user.ts @@ -10,12 +10,6 @@ export type UpdateUserOptions = { }; export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) => { - await prisma.user.findFirstOrThrow({ - where: { - id, - }, - }); - await prisma.user.update({ where: { 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/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/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/router.ts b/packages/trpc/server/admin-router/router.ts index 7c6f1558b..f8d472a79 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -11,6 +11,7 @@ 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 { updateAdminOrganisationRoute } from './update-admin-organisation'; @@ -36,6 +37,7 @@ export const adminRouter = router({ createCustomer: createStripeCustomerRoute, }, user: { + get: getUserRoute, update: updateUserRoute, delete: deleteUserRoute, enable: enableUserRoute, 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(), From 4012022f5562f0e133a43765fa3300546b5bf459 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 26 Aug 2025 11:08:43 +1000 Subject: [PATCH 10/47] fix: element visible race condition (#1996) On larger documents we could accidentally start trying to render fields while not all pages of the PDF have loaded due to us checking for a single page existing. This would cause an error to be thrown, hard locking those documents. This change resolves this by grabbing the highest page number from the given fields and using it for the visibility check instead. --- .../components/embed/authoring/configure-fields-view.tsx | 6 +++++- .../components/embed/embed-direct-template-client-page.tsx | 6 +++++- apps/remix/app/components/embed/embed-document-fields.tsx | 4 +++- .../app/components/embed/embed-document-signing-page.tsx | 6 +++++- .../embed/multisign/multi-sign-document-signing-view.tsx | 6 +++++- .../direct-template/direct-template-signing-form.tsx | 6 +++++- .../general/document-signing/document-signing-page-view.tsx | 6 +++++- .../_authenticated+/t.$teamUrl+/documents.$id._index.tsx | 1 + .../ui/components/document/document-read-only-fields.tsx | 4 +++- 9 files changed, 37 insertions(+), 8 deletions(-) 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..8b883a36b 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 @@ -91,6 +91,8 @@ 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 { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } = @@ -442,7 +444,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..d7d8a0713 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -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(); @@ -465,7 +467,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 && ( 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-page-view.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx index a1bf3d24e..cbbdc7926 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 @@ -78,6 +78,8 @@ export const DocumentSigningPageView = ({ const targetSigner = recipient.role === RecipientRole.ASSISTANT && selectedSigner ? selectedSigner : null; + const highestPageNumber = Math.max(...fields.map((field) => field.page)); + return (
@@ -224,7 +226,9 @@ export const DocumentSigningPageView = ({ )} - + {fields .filter( (field) => 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/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] && ( From 184ebdedf1051b99ab9da83d57faf541c81dad77 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 26 Aug 2025 11:17:43 +1000 Subject: [PATCH 11/47] v1.12.2-rc.6 --- apps/remix/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/remix/package.json b/apps/remix/package.json index 728f4ec26..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.5" + "version": "1.12.2-rc.6" } diff --git a/package-lock.json b/package-lock.json index 28385e051..647e4c43c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "1.12.2-rc.5", + "version": "1.12.2-rc.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "1.12.2-rc.5", + "version": "1.12.2-rc.6", "workspaces": [ "apps/*", "packages/*" @@ -89,7 +89,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "1.12.2-rc.5", + "version": "1.12.2-rc.6", "dependencies": { "@documenso/api": "*", "@documenso/assets": "*", diff --git a/package.json b/package.json index 86efef735..9e6654c0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "1.12.2-rc.5", + "version": "1.12.2-rc.6", "scripts": { "build": "turbo run build", "dev": "turbo run dev --filter=@documenso/remix", From 657db3bc84a4eb07539b58996203e180a3072840 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 28 Aug 2025 16:15:52 +1000 Subject: [PATCH 12/47] fix: improve mobile signing ux (#2003) Improves the mobile signing UX making actions available via the floating navbar more obvious. Also adds an automatic switch to the complete button once all fields have been signed. --- .../embed-direct-template-client-page.tsx | 45 ++++-- .../embed/embed-document-signing-page.tsx | 47 ++++-- .../document-signing-form.tsx | 89 ++--------- .../document-signing-page-view.tsx | 144 ++++++++++++++++-- 4 files changed, 210 insertions(+), 115 deletions(-) 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 8b883a36b..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, @@ -95,6 +95,8 @@ export const EmbedDirectTemplateClientPage = ({ const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE); + const signatureValid = !hasSignatureField || (signature && signature.trim() !== ''); + const { mutateAsync: createDocumentFromDirectTemplate, isPending: isSubmitting } = trpc.template.createDocumentFromDirectTemplate.useMutation(); @@ -345,19 +347,34 @@ export const EmbedDirectTemplateClientPage = ({ Sign document - + {isExpanded ? ( + + ) : pendingFields.length > 0 ? ( + + ) : ( + + )}
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 d7d8a0713..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); @@ -118,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 = () => { @@ -307,19 +309,36 @@ export const EmbedSignDocumentClientPage = ({ )} - + {isExpanded ? ( + + ) : pendingFields.length > 0 ? ( + + ) : ( + + )} 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 cbbdc7926..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,8 +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 (
@@ -165,19 +242,55 @@ export const DocumentSigningPageView = ({ .otherwise(() => null)} - + )) + .otherwise(() => ( + + > + + + ))}
@@ -206,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} />
From 31c1a9a7835dabd6029edf2634bf35847154ddd6 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Thu, 28 Aug 2025 06:19:14 +0000 Subject: [PATCH 13/47] fix: preserve existing recipient properties when adding new recipient (#1987) --- packages/api/v1/implementation.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index e9fcbf4d8..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, @@ -980,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, From 7d257236a6519b56595fcb5a968dac5c1e91748f Mon Sep 17 00:00:00 2001 From: samuel-cglg <124675162+samuel-cglg@users.noreply.github.com> Date: Thu, 28 Aug 2025 08:20:27 +0200 Subject: [PATCH 14/47] fix: default pagination on documents list API (#1929) --- packages/api/v1/schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index c33120ae3..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; @@ -637,5 +637,5 @@ export const ZSuccessfulGetTemplatesResponseSchema = z.object({ export const ZGetTemplatesQuerySchema = 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), }); From 2603ae8b905288c60f2f3abb299e6a320b639a43 Mon Sep 17 00:00:00 2001 From: samuel-cglg <124675162+samuel-cglg@users.noreply.github.com> Date: Sun, 31 Aug 2025 03:37:49 +0200 Subject: [PATCH 15/47] fix: send signing request email after the document status is updated (#1944) When sending a document for signing, emails for recipients are sent before the document status is updated. In this case, the job "send.signing.requested.email" fails because it cannot find the document with a PENDING status. --- .../server-only/document/send-document.tsx | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) 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)), From 19565c182160ec098a832bb033ced01e006a2723 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Sun, 31 Aug 2025 02:17:31 +0000 Subject: [PATCH 16/47] fix: access audit logs for documents in folder (#1989) --- .../_authenticated+/t.$teamUrl+/documents.$id.logs.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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(); From bb5c2edefd909fed06254666ee22da2c442151e2 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 2 Sep 2025 14:01:16 +0300 Subject: [PATCH 17/47] feat: implement auto-save functionality for signers in document edit form (#1792) --- .../general/document/document-edit-form.tsx | 222 +++++++++---- .../general/template/template-edit-form.tsx | 137 +++++--- .../t.$teamUrl+/templates._index.tsx | 2 +- .../autosave-fields-step.spec.ts | 293 +++++++++++++++++ .../autosave-settings-step.spec.ts | 243 ++++++++++++++ .../autosave-signers-step.spec.ts | 168 ++++++++++ .../autosave-subject-step.spec.ts | 200 ++++++++++++ .../document-flow/stepper-component.spec.ts | 6 +- .../template-autosave-fields-step.spec.ts | 304 ++++++++++++++++++ .../template-autosave-settings-step.spec.ts | 244 ++++++++++++++ .../template-autosave-signers-step.spec.ts | 174 ++++++++++ packages/app-tests/playwright.config.ts | 2 +- .../lib/client-only/hooks/use-autosave.ts | 31 ++ .../recipient/get-recipients-for-template.ts | 23 +- .../primitives/document-flow/add-fields.tsx | 46 ++- .../primitives/document-flow/add-settings.tsx | 70 +++- .../primitives/document-flow/add-signers.tsx | 170 ++++++++-- .../primitives/document-flow/add-subject.tsx | 37 ++- .../field-item-advanced-settings.tsx | 36 ++- .../template-flow/add-template-fields.tsx | 71 +++- .../add-template-placeholder-recipients.tsx | 126 ++++++-- .../template-flow/add-template-settings.tsx | 97 +++++- 22 files changed, 2482 insertions(+), 220 deletions(-) create mode 100644 packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts create mode 100644 packages/app-tests/e2e/document-flow/autosave-settings-step.spec.ts create mode 100644 packages/app-tests/e2e/document-flow/autosave-signers-step.spec.ts create mode 100644 packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts create mode 100644 packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts create mode 100644 packages/app-tests/e2e/templates-flow/template-autosave-settings-step.spec.ts create mode 100644 packages/app-tests/e2e/templates-flow/template-autosave-signers-step.spec.ts create mode 100644 packages/lib/client-only/hooks/use-autosave.ts 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 df73d92b6..e8ffa5fe5 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -159,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); @@ -199,26 +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([ - 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 ?? [], - })), - }), - ]); + await saveSignersData(data); setStep('fields'); } catch (err) { @@ -232,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++) { @@ -259,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.`), @@ -304,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]; /** @@ -349,25 +439,28 @@ export const DocumentEditForm = ({ fields={fields} isDocumentPdfLoaded={isDocumentPdfLoaded} onSubmit={onAddSettingsFormSubmit} + onAutoSave={onAddSettingsFormAutoSave} /> @@ -379,6 +472,7 @@ export const DocumentEditForm = ({ recipients={recipients} fields={fields} onSubmit={onAddSubjectFormSubmit} + onAutoSave={onAddSubjectFormAutoSave} isDocumentPdfLoaded={isDocumentPdfLoaded} /> 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/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/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts new file mode 100644 index 000000000..247f87319 --- /dev/null +++ b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts @@ -0,0 +1,293 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +const setupDocumentAndNavigateToFieldsStep = 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: '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/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/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/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 = ({ -