From ebbe922982a33e00280965827d3b8241bf1ff999 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sat, 11 Jan 2025 15:33:20 +1100 Subject: [PATCH] feat: add template and field endpoints (#1572) --- .../[id]/document-page-view-button.tsx | 3 +- .../[id]/document-page-view-dropdown.tsx | 1 - .../documents/[id]/document-page-view.tsx | 1 + .../documents/[id]/edit-document.tsx | 44 +- .../[id]/logs/download-audit-log-button.tsx | 8 +- .../[id]/logs/download-certificate-button.tsx | 2 +- .../_action-items/resend-document.tsx | 2 +- .../documents/data-table-action-button.tsx | 1 - .../documents/data-table-action-dropdown.tsx | 1 - .../documents/delete-document-dialog.tsx | 3 +- .../documents/duplicate-document-dialog.tsx | 3 +- .../(dashboard)/documents/upload-document.tsx | 1 - .../public-profile-page-view.tsx | 1 - .../public-templates-data-table.tsx | 7 +- .../templates/[id]/edit/edit-template.tsx | 53 +-- .../template-page-view-documents-table.tsx | 1 - .../template-page-view-recent-activity.tsx | 2 - .../templates/delete-template-dialog.tsx | 9 +- .../templates/duplicate-template-dialog.tsx | 2 - .../templates/new-template-dialog.tsx | 3 +- .../templates/template-direct-link-dialog.tsx | 2 - .../templates/use-template-dialog.tsx | 5 - .../%5F%5Fhtmltopdf/audit-log/page.tsx | 1 + .../src/app/(teams)/t/[teamUrl]/layout.tsx | 9 +- .../manage-public-template-dialog.tsx | 2 - apps/web/src/pages/api/trpc/[trpc].ts | 2 +- .../pages/api/{beta => v2-beta}/[...trpc].ts | 2 +- .../openapi.json.ts} | 0 packages/api/v1/implementation.ts | 90 +++- packages/api/v1/middleware/authenticated.ts | 22 +- .../document-meta/upsert-document-meta.ts | 49 +- .../server-only/document/create-document.ts | 7 +- .../server-only/document/delete-document.ts | 25 +- .../document/find-document-audit-logs.ts | 27 +- .../document/move-document-to-team.ts | 23 +- .../server-only/document/resend-document.tsx | 7 +- .../server-only/document/send-document.tsx | 22 +- .../document/update-document-settings.ts | 281 ------------ .../server-only/document/update-document.ts | 250 +++++++++- .../field/create-document-fields.ts | 148 ++++++ .../field/create-template-fields.ts | 122 +++++ .../field/delete-document-field.ts | 122 +++++ .../field/delete-template-field.ts | 48 ++ .../field/get-fields-for-document.ts | 22 +- .../field/get-fields-for-template.ts | 35 -- .../field/set-fields-for-document.ts | 51 +-- .../field/set-fields-for-template.ts | 27 +- .../field/update-document-fields.ts | 165 +++++++ .../field/update-template-fields.ts | 129 ++++++ .../server-only/profile/set-avatar-image.ts | 4 +- .../recipient/create-document-recipients.ts | 167 +++++++ .../recipient/create-template-recipients.ts | 139 ++++++ .../recipient/delete-document-recipient.ts | 161 +++++++ .../recipient/delete-template-recipient.ts | 67 +++ .../recipient/get-recipient-by-id.ts | 30 +- .../recipient/get-recipients-for-document.ts | 16 +- .../recipient/get-recipients-for-template.ts | 17 +- ...document.ts => set-document-recipients.ts} | 35 +- ...template.ts => set-template-recipients.ts} | 37 +- .../recipient/update-document-recipients.ts | 246 ++++++++++ .../recipient/update-template-recipients.ts | 185 ++++++++ .../create-document-from-direct-template.ts | 12 +- .../template/create-document-from-template.ts | 13 +- .../template/create-template-direct-link.ts | 27 +- .../template/delete-template-direct-link.ts | 27 +- .../server-only/template/delete-template.ts | 31 +- .../template/duplicate-template.ts | 40 +- .../template/get-template-by-id.ts | 40 +- .../template/toggle-template-direct-link.ts | 27 +- ...emplate-settings.ts => update-template.ts} | 40 +- packages/lib/types/document-audit-logs.ts | 7 +- packages/lib/types/document-auth.ts | 32 +- packages/lib/types/document-email.ts | 45 +- packages/lib/types/search-params.ts | 17 +- .../lib/universal/extract-request-metadata.ts | 30 ++ packages/lib/utils/document-audit-logs.ts | 30 +- packages/trpc/react/index.tsx | 6 +- packages/trpc/server/auth-router/router.ts | 7 +- packages/trpc/server/context.ts | 30 +- .../trpc/server/document-router/router.ts | 236 +++++----- .../trpc/server/document-router/schema.ts | 187 ++++---- packages/trpc/server/field-router/router.ts | 342 +++++++++++++- packages/trpc/server/field-router/schema.ts | 77 +++- packages/trpc/server/open-api.ts | 6 +- packages/trpc/server/profile-router/router.ts | 6 +- .../trpc/server/recipient-router/router.ts | 429 ++++++++++++++++-- .../trpc/server/recipient-router/schema.ts | 190 +++++++- .../trpc/server/template-router/router.ts | 184 +++----- .../trpc/server/template-router/schema.ts | 150 +++--- packages/trpc/server/trpc.ts | 54 ++- .../router.ts | 5 +- .../field-item-advanced-settings.tsx | 42 +- 92 files changed, 3920 insertions(+), 1396 deletions(-) rename apps/web/src/pages/api/{beta => v2-beta}/[...trpc].ts (96%) rename apps/web/src/pages/api/{beta/open-api.json.ts => v2-beta/openapi.json.ts} (100%) delete mode 100644 packages/lib/server-only/document/update-document-settings.ts create mode 100644 packages/lib/server-only/field/create-document-fields.ts create mode 100644 packages/lib/server-only/field/create-template-fields.ts create mode 100644 packages/lib/server-only/field/delete-document-field.ts create mode 100644 packages/lib/server-only/field/delete-template-field.ts delete mode 100644 packages/lib/server-only/field/get-fields-for-template.ts create mode 100644 packages/lib/server-only/field/update-document-fields.ts create mode 100644 packages/lib/server-only/field/update-template-fields.ts create mode 100644 packages/lib/server-only/recipient/create-document-recipients.ts create mode 100644 packages/lib/server-only/recipient/create-template-recipients.ts create mode 100644 packages/lib/server-only/recipient/delete-document-recipient.ts create mode 100644 packages/lib/server-only/recipient/delete-template-recipient.ts rename packages/lib/server-only/recipient/{set-recipients-for-document.ts => set-document-recipients.ts} (91%) rename packages/lib/server-only/recipient/{set-recipients-for-template.ts => set-template-recipients.ts} (91%) create mode 100644 packages/lib/server-only/recipient/update-document-recipients.ts create mode 100644 packages/lib/server-only/recipient/update-template-recipients.ts rename packages/lib/server-only/template/{update-template-settings.ts => update-template.ts} (77%) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx index 800cc23c2..c68ee44e7 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-button.tsx @@ -25,7 +25,7 @@ export type DocumentPageViewButtonProps = { team?: Pick; }; -export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButtonProps) => { +export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => { const { data: session } = useSession(); const { toast } = useToast(); const { _ } = useLingui(); @@ -48,7 +48,6 @@ export const DocumentPageViewButton = ({ document, team }: DocumentPageViewButto try { const documentWithData = await trpcClient.document.getDocumentById.query({ documentId: document.id, - teamId: team?.id, }); const documentData = documentWithData?.documentData; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx index 9f66dcbb6..a44195979 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -76,7 +76,6 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro try { const documentWithData = await trpcClient.document.getDocumentById.query({ documentId: document.id, - teamId: team?.id, }); const documentData = documentWithData?.documentData; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index bc890057c..d62207f05 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -125,6 +125,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) getFieldsForDocument({ documentId, userId: user.id, + teamId: team?.id, }), ]); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 01a483d86..14978610d 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -64,7 +64,6 @@ export const EditDocumentForm = ({ trpc.document.getDocumentWithDetailsById.useQuery( { documentId: initialDocument.id, - teamId: team?.id, }, { initialData: initialDocument, @@ -74,13 +73,12 @@ export const EditDocumentForm = ({ const { Recipient: recipients, Field: fields } = document; - const { mutateAsync: setSettingsForDocument } = trpc.document.setSettingsForDocument.useMutation({ + const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newData) => { utils.document.getDocumentWithDetailsById.setData( { documentId: initialDocument.id, - teamId: team?.id, }, (oldData) => ({ ...(oldData || initialDocument), ...newData }), ); @@ -94,7 +92,6 @@ export const EditDocumentForm = ({ utils.document.getDocumentWithDetailsById.setData( { documentId: initialDocument.id, - teamId: team?.id, }, (oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }), ); @@ -107,38 +104,18 @@ export const EditDocumentForm = ({ utils.document.getDocumentWithDetailsById.setData( { documentId: initialDocument.id, - teamId: team?.id, }, (oldData) => ({ ...(oldData || initialDocument), Field: newFields }), ); }, }); - const { mutateAsync: updateTypedSignature } = - trpc.document.updateTypedSignatureSettings.useMutation({ - ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, - onSuccess: (newData) => { - utils.document.getDocumentWithDetailsById.setData( - { - documentId: initialDocument.id, - teamId: team?.id, - }, - (oldData) => ({ - ...(oldData || initialDocument), - ...newData, - id: Number(newData.id), - }), - ); - }, - }); - - const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({ + const { mutateAsync: setRecipients } = trpc.recipient.setDocumentRecipients.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: ({ recipients: newRecipients }) => { utils.document.getDocumentWithDetailsById.setData( { documentId: initialDocument.id, - teamId: team?.id, }, (oldData) => ({ ...(oldData || initialDocument), Recipient: newRecipients }), ); @@ -151,7 +128,6 @@ export const EditDocumentForm = ({ utils.document.getDocumentWithDetailsById.setData( { documentId: initialDocument.id, - teamId: team?.id, }, (oldData) => ({ ...(oldData || initialDocument), ...newData }), ); @@ -205,9 +181,8 @@ export const EditDocumentForm = ({ try { const { timezone, dateFormat, redirectUrl, language } = data.meta; - await setSettingsForDocument({ + await updateDocument({ documentId: document.id, - teamId: team?.id, data: { title: data.title, externalId: data.externalId || null, @@ -246,10 +221,9 @@ export const EditDocumentForm = ({ signingOrder: data.signingOrder, }), - addSigners({ + setRecipients({ documentId: document.id, - teamId: team?.id, - signers: data.signers.map((signer) => ({ + recipients: data.signers.map((signer) => ({ ...signer, // Explicitly set to null to indicate we want to remove auth if required. actionAuth: signer.actionAuth || null, @@ -279,9 +253,12 @@ export const EditDocumentForm = ({ fields: data.fields, }); - await updateTypedSignature({ + await updateDocument({ documentId: document.id, - typedSignatureEnabled: data.typedSignatureEnabled, + + meta: { + typedSignatureEnabled: data.typedSignatureEnabled, + }, }); // Clear all field data from localStorage @@ -313,7 +290,6 @@ export const EditDocumentForm = ({ try { await sendDocument({ documentId: document.id, - teamId: team?.id, meta: { subject, message, diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx index 70b81238c..da408d6f9 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-audit-log-button.tsx @@ -15,11 +15,7 @@ export type DownloadAuditLogButtonProps = { documentId: number; }; -export const DownloadAuditLogButton = ({ - className, - teamId, - documentId, -}: DownloadAuditLogButtonProps) => { +export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => { const { toast } = useToast(); const { _ } = useLingui(); @@ -28,7 +24,7 @@ export const DownloadAuditLogButton = ({ const onDownloadAuditLogsClick = async () => { try { - const { url } = await downloadAuditLogs({ teamId, documentId }); + const { url } = await downloadAuditLogs({ documentId }); const iframe = Object.assign(document.createElement('iframe'), { src: url, diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx index 7cc262d3d..bfb894e24 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx @@ -31,7 +31,7 @@ export const DownloadCertificateButton = ({ const onDownloadCertificatesClick = async () => { try { - const { url } = await downloadCertificate({ documentId, teamId }); + const { url } = await downloadCertificate({ documentId }); const iframe = Object.assign(document.createElement('iframe'), { src: url, diff --git a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx index 01b532887..f45a3262f 100644 --- a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx @@ -91,7 +91,7 @@ export const ResendDocumentActionItem = ({ const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => { try { - await resendDocument({ documentId: document.id, recipients, teamId: team?.id }); + await resendDocument({ documentId: document.id, recipients }); toast({ title: _(msg`Document re-sent`), diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 45be9b24e..b926fd1e8 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -55,7 +55,6 @@ export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) if (!recipient) { document = await trpcClient.document.getDocumentById.query({ documentId: row.id, - teamId: team?.id, }); } else { document = await trpcClient.document.getDocumentByToken.query({ diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index 237ba009e..84c6e7fa7 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -86,7 +86,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr if (!recipient) { document = await trpcClient.document.getDocumentById.query({ documentId: row.id, - teamId: team?.id, }); } else { document = await trpcClient.document.getDocumentByToken.query({ diff --git a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx index f8ab254e1..176e5d7fd 100644 --- a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx @@ -38,7 +38,6 @@ export const DeleteDocumentDialog = ({ onOpenChange, status, documentTitle, - teamId, canManageDocument, }: DeleteDocumentDialogProps) => { const router = useRouter(); @@ -76,7 +75,7 @@ export const DeleteDocumentDialog = ({ const onDelete = async () => { try { - await deleteDocument({ documentId: id, teamId }); + await deleteDocument({ documentId: id }); } catch { toast({ title: _(msg`Something went wrong`), diff --git a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx index 67bf5feae..9a33d45b0 100644 --- a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx @@ -37,7 +37,6 @@ export const DuplicateDocumentDialog = ({ const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({ documentId: id, - teamId: team?.id, }); const documentData = document?.documentData @@ -66,7 +65,7 @@ export const DuplicateDocumentDialog = ({ const onDuplicate = async () => { try { - await duplicateDocument({ documentId: id, teamId: team?.id }); + await duplicateDocument({ documentId: id }); } catch { toast({ title: _(msg`Something went wrong`), diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx index daca2443b..2e54f845d 100644 --- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx @@ -76,7 +76,6 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => { const { id } = await createDocument({ title: file.name, documentDataId, - teamId: team?.id, timezone: userTimezone, }); diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx index 9dae25fcb..fd1eea194 100644 --- a/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx +++ b/apps/web/src/app/(dashboard)/settings/public-profile/public-profile-page-view.tsx @@ -61,7 +61,6 @@ export const PublicProfilePageView = ({ user, team, profile }: PublicProfilePage const { data } = trpc.template.findTemplates.useQuery({ perPage: 100, - teamId: team?.id, }); const { mutateAsync: updateUserProfile, isLoading: isUpdatingUserProfile } = diff --git a/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx b/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx index 127fd5c6d..c2ceeed6d 100644 --- a/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx +++ b/apps/web/src/app/(dashboard)/settings/public-profile/public-templates-data-table.tsx @@ -23,15 +23,12 @@ import { Skeleton } from '@documenso/ui/primitives/skeleton'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog'; -import { useOptionalCurrentTeam } from '~/providers/team'; type DirectTemplate = FindTemplateRow & { directLink: Pick; }; export const PublicTemplatesDataTable = () => { - const team = useOptionalCurrentTeam(); - const { _ } = useLingui(); const { toast } = useToast(); @@ -43,9 +40,7 @@ export const PublicTemplatesDataTable = () => { } | null>(null); const { data, isInitialLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery( - { - teamId: team?.id, - }, + {}, { keepPreviousData: true, }, diff --git a/apps/web/src/app/(dashboard)/templates/[id]/edit/edit-template.tsx b/apps/web/src/app/(dashboard)/templates/[id]/edit/edit-template.tsx index 65b45fc5c..c1c5881da 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/edit/edit-template.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit/edit-template.tsx @@ -62,7 +62,6 @@ export const EditTemplateForm = ({ const { data: template, refetch: refetchTemplate } = trpc.template.getTemplateById.useQuery( { templateId: initialTemplate.id, - teamId: initialTemplate.teamId || undefined, }, { initialData: initialTemplate, @@ -104,19 +103,6 @@ export const EditTemplateForm = ({ }, }); - const { mutateAsync: setSigningOrderForTemplate } = - trpc.template.setSigningOrderForTemplate.useMutation({ - ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, - onSuccess: (newData) => { - utils.template.getTemplateById.setData( - { - templateId: initialTemplate.id, - }, - (oldData) => ({ ...(oldData || initialTemplate), ...newData }), - ); - }, - }); - const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newData) => { @@ -129,7 +115,7 @@ export const EditTemplateForm = ({ }, }); - const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation({ + const { mutateAsync: setRecipients } = trpc.recipient.setTemplateRecipients.useMutation({ ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, onSuccess: (newData) => { utils.template.getTemplateById.setData( @@ -141,28 +127,10 @@ export const EditTemplateForm = ({ }, }); - const { mutateAsync: updateTypedSignature } = - trpc.template.updateTemplateTypedSignatureSettings.useMutation({ - ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, - onSuccess: (newData) => { - utils.template.getTemplateById.setData( - { - templateId: initialTemplate.id, - }, - (oldData) => ({ - ...(oldData || initialTemplate), - ...newData, - id: Number(newData.id), - }), - ); - }, - }); - const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { try { await updateTemplateSettings({ templateId: template.id, - teamId: team?.id, data: { title: data.title, externalId: data.externalId || null, @@ -196,16 +164,16 @@ export const EditTemplateForm = ({ ) => { try { await Promise.all([ - setSigningOrderForTemplate({ + updateTemplateSettings({ templateId: template.id, - teamId: team?.id, - signingOrder: data.signingOrder, + meta: { + signingOrder: data.signingOrder, + }, }), - addTemplateSigners({ + setRecipients({ templateId: template.id, - teamId: team?.id, - signers: data.signers, + recipients: data.signers, }), ]); @@ -229,10 +197,11 @@ export const EditTemplateForm = ({ fields: data.fields, }); - await updateTypedSignature({ + await updateTemplateSettings({ templateId: template.id, - teamId: team?.id, - typedSignatureEnabled: data.typedSignatureEnabled, + meta: { + typedSignatureEnabled: data.typedSignatureEnabled, + }, }); // Clear all field data from localStorage diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-documents-table.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-documents-table.tsx index 829836152..dc51d2593 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-documents-table.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-documents-table.tsx @@ -73,7 +73,6 @@ export const TemplatePageViewDocumentsTable = ({ trpc.document.findDocuments.useQuery( { templateId, - teamId: team?.id, page: parsedSearchParams.page, perPage: parsedSearchParams.perPage, query: parsedSearchParams.query, diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-recent-activity.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-recent-activity.tsx index 53cb7a148..f8b942f48 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-recent-activity.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view-recent-activity.tsx @@ -20,12 +20,10 @@ export type TemplatePageViewRecentActivityProps = { export const TemplatePageViewRecentActivity = ({ templateId, - teamId, documentRootPath, }: TemplatePageViewRecentActivityProps) => { const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({ templateId, - teamId, orderByColumn: 'createdAt', orderByDirection: 'asc', perPage: 5, diff --git a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx index 586668207..c0f30508b 100644 --- a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx @@ -22,12 +22,7 @@ type DeleteTemplateDialogProps = { onOpenChange: (_open: boolean) => void; }; -export const DeleteTemplateDialog = ({ - id, - teamId, - open, - onOpenChange, -}: DeleteTemplateDialogProps) => { +export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => { const router = useRouter(); const { _ } = useLingui(); @@ -85,7 +80,7 @@ export const DeleteTemplateDialog = ({ type="button" variant="destructive" loading={isLoading} - onClick={async () => deleteTemplate({ templateId: id, teamId })} + onClick={async () => deleteTemplate({ templateId: id })} > Delete diff --git a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx index 3225834de..7bd71f71d 100644 --- a/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/duplicate-template-dialog.tsx @@ -24,7 +24,6 @@ type DuplicateTemplateDialogProps = { export const DuplicateTemplateDialog = ({ id, - teamId, open, onOpenChange, }: DuplicateTemplateDialogProps) => { @@ -84,7 +83,6 @@ export const DuplicateTemplateDialog = ({ onClick={async () => duplicateTemplate({ templateId: id, - teamId, }) } > diff --git a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx index 3ce5e0789..f6e31c3e0 100644 --- a/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/new-template-dialog.tsx @@ -31,7 +31,7 @@ type NewTemplateDialogProps = { templateRootPath: string; }; -export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => { +export const NewTemplateDialog = ({ templateRootPath }: NewTemplateDialogProps) => { const router = useRouter(); const { data: session } = useSession(); @@ -58,7 +58,6 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo }); const { id } = await createTemplate({ - teamId, title: file.name, templateDocumentDataId, }); diff --git a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx index 90cd2c5d4..f632c551c 100644 --- a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx @@ -174,7 +174,6 @@ export const TemplateDirectLinkDialog = ({ await createTemplateDirectLink({ templateId: template.id, - teamId: team?.id, directRecipientId: recipientId, }); }; @@ -345,7 +344,6 @@ export const TemplateDirectLinkDialog = ({ onClick={async () => createTemplateDirectLink({ templateId: template.id, - teamId: team?.id, }) } > diff --git a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx index 811a39e55..6c7508327 100644 --- a/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/use-template-dialog.tsx @@ -47,8 +47,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive import type { Toast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useOptionalCurrentTeam } from '~/providers/team'; - const ZAddRecipientsForNewDocumentSchema = z .object({ distributeDocument: z.boolean(), @@ -120,8 +118,6 @@ export function UseTemplateDialog({ const [open, setOpen] = useState(false); - const team = useOptionalCurrentTeam(); - const form = useForm({ resolver: zodResolver(ZAddRecipientsForNewDocumentSchema), defaultValues: { @@ -163,7 +159,6 @@ export function UseTemplateDialog({ const { id } = await createDocumentFromTemplate({ templateId, - teamId: team?.id, recipients: data.recipients, distributeDocument: data.distributeDocument, customDocumentDataId, diff --git a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx index f8e510e65..107b1e04d 100644 --- a/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx +++ b/apps/web/src/app/(internal)/%5F%5Fhtmltopdf/audit-log/page.tsx @@ -66,6 +66,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) { const { data: auditLogs } = await findDocumentAuditLogs({ documentId: documentId, userId: document.userId, + teamId: document.teamId || undefined, perPage: 100_000, }); diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx index f5d2a48d1..47a9bda70 100644 --- a/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx +++ b/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx @@ -8,6 +8,7 @@ import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-c import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { getTeams } from '@documenso/lib/server-only/team/get-teams'; import { SubscriptionStatus } from '@documenso/prisma/client'; +import { TrpcProvider } from '@documenso/trpc/react'; import { Header } from '~/components/(dashboard)/layout/header'; import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus'; @@ -47,6 +48,10 @@ export default async function AuthenticatedTeamsLayout({ const team = getTeamPromise.value; const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : []; + const trpcHeaders = { + 'x-team-Id': team.id.toString(), + }; + return ( @@ -61,7 +66,9 @@ export default async function AuthenticatedTeamsLayout({
-
{children}
+ +
{children}
+
diff --git a/apps/web/src/components/templates/manage-public-template-dialog.tsx b/apps/web/src/components/templates/manage-public-template-dialog.tsx index bc5dbe7df..405ee48f7 100644 --- a/apps/web/src/components/templates/manage-public-template-dialog.tsx +++ b/apps/web/src/components/templates/manage-public-template-dialog.tsx @@ -123,7 +123,6 @@ export const ManagePublicTemplateDialog = ({ try { await updateTemplateSettings({ templateId, - teamId: team?.id, data: { type: TemplateType.PRIVATE, }, @@ -158,7 +157,6 @@ export const ManagePublicTemplateDialog = ({ try { await updateTemplateSettings({ templateId: selectedTemplateId, - teamId: team?.id, data: { type: TemplateType.PUBLIC, publicTitle, diff --git a/apps/web/src/pages/api/trpc/[trpc].ts b/apps/web/src/pages/api/trpc/[trpc].ts index 0e572bffc..dde0962b2 100644 --- a/apps/web/src/pages/api/trpc/[trpc].ts +++ b/apps/web/src/pages/api/trpc/[trpc].ts @@ -17,7 +17,7 @@ const logger = buildLogger(); export default trpcNext.createNextApiHandler({ router: appRouter, - createContext: async ({ req, res }) => createTrpcContext({ req, res }), + createContext: async ({ req, res }) => createTrpcContext({ req, res, requestSource: 'app' }), onError(opts) { const { error, path } = opts; diff --git a/apps/web/src/pages/api/beta/[...trpc].ts b/apps/web/src/pages/api/v2-beta/[...trpc].ts similarity index 96% rename from apps/web/src/pages/api/beta/[...trpc].ts rename to apps/web/src/pages/api/v2-beta/[...trpc].ts index edba9ae92..4f1e50375 100644 --- a/apps/web/src/pages/api/beta/[...trpc].ts +++ b/apps/web/src/pages/api/v2-beta/[...trpc].ts @@ -13,7 +13,7 @@ const logger = buildLogger(); export default createOpenApiNextHandler({ router: appRouter, createContext: async ({ req, res }: { req: NextApiRequest; res: NextApiResponse }) => - createTrpcContext({ req, res }), + createTrpcContext({ req, res, requestSource: 'apiV2' }), onError: ({ error, path }: { error: TRPCError; path?: string }) => { // Always log the error for now. console.error(error.message); diff --git a/apps/web/src/pages/api/beta/open-api.json.ts b/apps/web/src/pages/api/v2-beta/openapi.json.ts similarity index 100% rename from apps/web/src/pages/api/beta/open-api.json.ts rename to apps/web/src/pages/api/v2-beta/openapi.json.ts diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 76ba25878..dd78c98d0 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -16,8 +16,7 @@ import { findDocuments } from '@documenso/lib/server-only/document/find-document import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; -import { updateDocument } from '@documenso/lib/server-only/document/update-document'; -import { updateDocumentSettings } from '@documenso/lib/server-only/document/update-document-settings'; +import { updateDocument as updateDocumentSettings } from '@documenso/lib/server-only/document/update-document'; import { deleteField } from '@documenso/lib/server-only/field/delete-field'; import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id'; import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; @@ -26,7 +25,7 @@ import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-for import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient'; import { getRecipientByIdV1Api } from '@documenso/lib/server-only/recipient/get-recipient-by-id-v1-api'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; -import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; +import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients'; import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; import { createTeamMemberInvites } from '@documenso/lib/server-only/team/create-team-member-invites'; import { deleteTeamMembers } from '@documenso/lib/server-only/team/delete-team-members'; @@ -54,6 +53,7 @@ import { } from '@documenso/lib/universal/upload/server-actions'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; import { DocumentDataType, DocumentStatus, @@ -98,13 +98,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { const recipients = await getRecipientsForDocument({ documentId: Number(documentId), - teamId: team?.id, userId: user.id, + teamId: team?.id, }); const fields = await getFieldsForDocument({ documentId: Number(documentId), userId: user.id, + teamId: team?.id, }); const parsedMetaFields = fields.map((field) => { @@ -209,7 +210,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { } }), - deleteDocument: authenticatedMiddleware(async (args, user, team) => { + deleteDocument: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { id: documentId } = args.params; try { @@ -232,6 +233,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { id: document.id, userId: user.id, teamId: team?.id, + requestMetadata: metadata, }); return { @@ -248,7 +250,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { } }), - createDocument: authenticatedMiddleware(async (args, user, team) => { + createDocument: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { body } = args; try { @@ -316,12 +318,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { teamId: team?.id, formValues: body.formValues, documentDataId: documentData.id, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata, }); await upsertDocumentMeta({ documentId: document.id, userId: user.id, + teamId: team?.id, subject: body.meta.subject, message: body.meta.message, timezone, @@ -332,7 +335,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { typedSignatureEnabled: body.meta.typedSignatureEnabled, distributionMethod: body.meta.distributionMethod, emailSettings: body.meta.emailSettings, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata, }); if (body.authOptions) { @@ -343,16 +346,16 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { data: { ...body.authOptions, }, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata, }); } - const { recipients } = await setRecipientsForDocument({ + const { recipients } = await setDocumentRecipients({ userId: user.id, teamId: team?.id, documentId: document.id, recipients: body.recipients, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata, }); return { @@ -453,7 +456,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { } }), - createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => { + createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { body, params } = args; const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id }); @@ -517,8 +520,9 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { await upsertDocumentMeta({ documentId: document.id, userId: user.id, + teamId: team?.id, ...body.meta, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata, }); } @@ -528,7 +532,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { userId: user.id, teamId: team?.id, data: body.authOptions, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata, }); } @@ -550,7 +554,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { }; }), - generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => { + generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { body, params } = args; const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id }); @@ -579,6 +583,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { title: body.title, ...body.meta, }, + requestMetadata: metadata, }); } catch (err) { return AppError.toRestAPIError(err); @@ -621,7 +626,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { userId: user.id, teamId: team?.id, data: body.authOptions, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata, }); } @@ -642,7 +647,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { }; }), - sendDocument: authenticatedMiddleware(async (args, user, team) => { + sendDocument: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { id: documentId } = args.params; const { sendEmail, sendCompletionEmails } = args.body; @@ -678,12 +683,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { await upsertDocumentMeta({ documentId: document.id, userId: user.id, + teamId: team?.id, emailSettings: { ...emailSettings, documentCompleted: sendCompletionEmails, ownerDocumentCompleted: sendCompletionEmails, }, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata, }); } @@ -692,7 +698,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { userId: user.id, teamId: team?.id, sendEmail, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata, }); return { @@ -716,7 +722,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { } }), - resendDocument: authenticatedMiddleware(async (args, user, team) => { + resendDocument: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { id: documentId } = args.params; const { recipients } = args.body; @@ -726,7 +732,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { documentId: Number(documentId), recipients, teamId: team?.id, - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata, }); return { @@ -745,7 +751,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { } }), - createRecipient: authenticatedMiddleware(async (args, user, team) => { + createRecipient: authenticatedMiddleware(async (args, user, team, { metadata }) => { const { id: documentId } = args.params; const { name, email, role, authOptions, signingOrder } = args.body; @@ -791,7 +797,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { } try { - const { recipients: newRecipients } = await setRecipientsForDocument({ + const { recipients: newRecipients } = await setDocumentRecipients({ documentId: Number(documentId), userId: user.id, teamId: team?.id, @@ -809,7 +815,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { actionAuth: authOptions?.actionAuth ?? null, }, ], - requestMetadata: extractNextApiRequestMetadata(args.req), + requestMetadata: metadata, }); const newRecipient = newRecipients.find((recipient) => recipient.email === email); @@ -1574,3 +1580,39 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { }; }), }); + +const updateDocument = async ({ + documentId, + userId, + teamId, + data, +}: { + documentId: number; + data: Prisma.DocumentUpdateInput; + userId: number; + teamId?: number; +}) => { + return await prisma.document.update({ + where: { + id: documentId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + data: { + ...data, + }, + }); +}; diff --git a/packages/api/v1/middleware/authenticated.ts b/packages/api/v1/middleware/authenticated.ts index 7f62706ca..b2ad28d2f 100644 --- a/packages/api/v1/middleware/authenticated.ts +++ b/packages/api/v1/middleware/authenticated.ts @@ -2,6 +2,8 @@ import type { NextApiRequest } from 'next'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { Team, User } from '@documenso/prisma/client'; export const authenticatedMiddleware = < @@ -13,7 +15,12 @@ export const authenticatedMiddleware = < body: unknown; }, >( - handler: (args: T, user: User, team?: Team | null) => Promise, + handler: ( + args: T, + user: User, + team: Team | null | undefined, + options: { metadata: ApiRequestMetadata }, + ) => Promise, ) => { return async (args: T) => { try { @@ -36,7 +43,18 @@ export const authenticatedMiddleware = < }); } - return await handler(args, apiToken.user, apiToken.team); + const metadata: ApiRequestMetadata = { + requestMetadata: extractNextApiRequestMetadata(args.req), + source: 'apiV1', + auth: 'api', + auditUser: { + id: apiToken.team ? null : apiToken.user.id, + email: apiToken.team ? null : apiToken.user.email, + name: apiToken.team?.name ?? apiToken.user.name, + }, + }; + + return await handler(args, apiToken.user, apiToken.team, { metadata }); } catch (err) { console.log({ err: err }); diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index c6f4fd7a3..3c70d9c02 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -1,7 +1,7 @@ 'use server'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; -import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { createDocumentAuditLogData, diffDocumentMetaChanges, @@ -13,6 +13,8 @@ import type { SupportedLanguageCodes } from '../../constants/i18n'; import type { TDocumentEmailSettings } from '../../types/document-email'; export type CreateDocumentMetaOptions = { + userId: number; + teamId?: number; documentId: number; subject?: string; message?: string; @@ -25,18 +27,18 @@ export type CreateDocumentMetaOptions = { distributionMethod?: DocumentDistributionMethod; typedSignatureEnabled?: boolean; language?: SupportedLanguageCodes; - userId: number; - requestMetadata: RequestMetadata; + requestMetadata: ApiRequestMetadata; }; export const upsertDocumentMeta = async ({ + userId, + teamId, subject, message, timezone, dateFormat, documentId, password, - userId, redirectUrl, signingOrder, emailSettings, @@ -45,34 +47,24 @@ export const upsertDocumentMeta = async ({ language, requestMetadata, }: CreateDocumentMetaOptions) => { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - select: { - id: true, - email: true, - name: true, - }, - }); - const { documentMeta: originalDocumentMeta } = await prisma.document.findFirstOrThrow({ where: { id: documentId, - OR: [ - { - userId: user.id, - }, - { - team: { - members: { - some: { - userId: user.id, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, }, }, - }, - }, - ], + } + : { + userId, + teamId: null, + }), }, include: { documentMeta: true, @@ -120,8 +112,7 @@ export const upsertDocumentMeta = async ({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED, documentId, - user, - requestMetadata, + metadata: requestMetadata, data: { changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta), }, diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index a0b857fbf..2a12c949d 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -5,7 +5,7 @@ import type { z } from 'zod'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; -import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; import { DocumentSource, DocumentVisibility, WebhookTriggerEvents } from '@documenso/prisma/client'; @@ -27,7 +27,7 @@ export type CreateDocumentOptions = { formValues?: Record; normalizePdf?: boolean; timezone?: string; - requestMetadata?: RequestMetadata; + requestMetadata: ApiRequestMetadata; }; export const ZCreateDocumentResponseSchema = DocumentSchema; @@ -162,8 +162,7 @@ export const createDocument = async ({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, documentId: document.id, - user, - requestMetadata, + metadata: requestMetadata, data: { title, source: { diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index f4d1beecc..1295b3cbe 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -20,9 +20,10 @@ import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; import { getI18nInstance } from '../../client-only/providers/i18n.server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; -import type { RequestMetadata } from '../../universal/extract-request-metadata'; +import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding'; @@ -31,7 +32,7 @@ export type DeleteDocumentOptions = { id: number; userId: number; teamId?: number; - requestMetadata?: RequestMetadata; + requestMetadata: ApiRequestMetadata; }; export const deleteDocument = async ({ @@ -47,7 +48,9 @@ export const deleteDocument = async ({ }); if (!user) { - throw new Error('User not found'); + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'User not found', + }); } const document = await prisma.document.findUnique({ @@ -67,7 +70,9 @@ export const deleteDocument = async ({ }); if (!document || (teamId !== undefined && teamId !== document.teamId)) { - throw new Error('Document not found'); + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found', + }); } const isUserOwner = document.userId === userId; @@ -75,7 +80,9 @@ export const deleteDocument = async ({ const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email); if (!isUserOwner && !isUserTeamMember && !userRecipient) { - throw new Error('Not allowed'); + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Not allowed', + }); } // Handle hard or soft deleting the actual document if user has permission. @@ -130,7 +137,7 @@ type HandleDocumentOwnerDeleteOptions = { }) | null; user: User; - requestMetadata?: RequestMetadata; + requestMetadata: ApiRequestMetadata; }; const handleDocumentOwnerDelete = async ({ @@ -150,8 +157,7 @@ const handleDocumentOwnerDelete = async ({ data: createDocumentAuditLogData({ documentId: document.id, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, - user, - requestMetadata, + metadata: requestMetadata, data: { type: 'SOFT', }, @@ -177,8 +183,7 @@ const handleDocumentOwnerDelete = async ({ data: createDocumentAuditLogData({ documentId: document.id, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, - user, - requestMetadata, + metadata: requestMetadata, data: { type: 'HARD', }, diff --git a/packages/lib/server-only/document/find-document-audit-logs.ts b/packages/lib/server-only/document/find-document-audit-logs.ts index d9eb4d7fd..a0108180d 100644 --- a/packages/lib/server-only/document/find-document-audit-logs.ts +++ b/packages/lib/server-only/document/find-document-audit-logs.ts @@ -8,6 +8,7 @@ import { parseDocumentAuditLogData } from '../../utils/document-audit-logs'; export interface FindDocumentAuditLogsOptions { userId: number; + teamId?: number; documentId: number; page?: number; perPage?: number; @@ -21,6 +22,7 @@ export interface FindDocumentAuditLogsOptions { export const findDocumentAuditLogs = async ({ userId, + teamId, documentId, page = 1, perPage = 30, @@ -34,20 +36,21 @@ export const findDocumentAuditLogs = async ({ const document = await prisma.document.findFirst({ where: { id: documentId, - OR: [ - { - userId, - }, - { - team: { - members: { - some: { - userId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, }, }, - }, - }, - ], + } + : { + userId, + teamId: null, + }), }, }); diff --git a/packages/lib/server-only/document/move-document-to-team.ts b/packages/lib/server-only/document/move-document-to-team.ts index 8aad63429..5f8875bc8 100644 --- a/packages/lib/server-only/document/move-document-to-team.ts +++ b/packages/lib/server-only/document/move-document-to-team.ts @@ -1,10 +1,10 @@ -import { TRPCError } from '@trpc/server'; import type { z } from 'zod'; -import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; import { DocumentSchema } from '@documenso/prisma/generated/zod'; +import { AppError, AppErrorCode } from '../../errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; @@ -12,7 +12,7 @@ export type MoveDocumentToTeamOptions = { documentId: number; teamId: number; userId: number; - requestMetadata?: RequestMetadata; + requestMetadata: ApiRequestMetadata; }; export const ZMoveDocumentToTeamResponseSchema = DocumentSchema; @@ -26,10 +26,6 @@ export const moveDocumentToTeam = async ({ requestMetadata, }: MoveDocumentToTeamOptions): Promise => { return await prisma.$transaction(async (tx) => { - const user = await tx.user.findUniqueOrThrow({ - where: { id: userId }, - }); - const document = await tx.document.findFirst({ where: { id: documentId, @@ -39,8 +35,7 @@ export const moveDocumentToTeam = async ({ }); if (!document) { - throw new TRPCError({ - code: 'NOT_FOUND', + throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Document not found or already associated with a team.', }); } @@ -57,9 +52,8 @@ export const moveDocumentToTeam = async ({ }); if (!team) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'You are not a member of this team.', + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'This team does not exist, or you are not a member of this team.', }); } @@ -68,12 +62,11 @@ export const moveDocumentToTeam = async ({ data: { teamId }, }); - const log = await tx.documentAuditLog.create({ + await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM, documentId: updatedDocument.id, - user, - requestMetadata, + metadata: requestMetadata, data: { movedByUserId: userId, fromPersonalAccount: true, diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index 1ed899fe1..d746dcf0c 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -10,7 +10,7 @@ import { RECIPIENT_ROLE_TO_EMAIL_TYPE, } from '@documenso/lib/constants/recipient-roles'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; -import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { prisma } from '@documenso/prisma'; @@ -29,7 +29,7 @@ export type ResendDocumentOptions = { userId: number; recipients: number[]; teamId?: number; - requestMetadata: RequestMetadata; + requestMetadata: ApiRequestMetadata; }; export const resendDocument = async ({ @@ -201,8 +201,7 @@ export const resendDocument = async ({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, documentId: document.id, - user, - requestMetadata, + metadata: requestMetadata, data: { emailType: recipientEmailType, recipientEmail: recipient.email, diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 8fc2674fc..e0867b9e2 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -1,7 +1,7 @@ import type { z } from 'zod'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; -import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; @@ -31,7 +31,7 @@ export type SendDocumentOptions = { userId: number; teamId?: number; sendEmail?: boolean; - requestMetadata?: RequestMetadata; + requestMetadata: ApiRequestMetadata; }; export const ZSendDocumentResponseSchema = DocumentSchema.extend({ @@ -48,17 +48,6 @@ export const sendDocument = async ({ sendEmail, requestMetadata, }: SendDocumentOptions): Promise => { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - select: { - id: true, - name: true, - email: true, - }, - }); - const document = await prisma.document.findUnique({ where: { id: documentId, @@ -198,7 +187,7 @@ export const sendDocument = async ({ userId, documentId, recipientId: recipient.id, - requestMetadata, + requestMetadata: requestMetadata?.requestMetadata, }, }); }), @@ -215,7 +204,7 @@ export const sendDocument = async ({ name: 'internal.seal-document', payload: { documentId, - requestMetadata, + requestMetadata: requestMetadata?.requestMetadata, }, }); @@ -237,8 +226,7 @@ export const sendDocument = async ({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, documentId: document.id, - requestMetadata, - user, + metadata: requestMetadata, data: {}, }), }); diff --git a/packages/lib/server-only/document/update-document-settings.ts b/packages/lib/server-only/document/update-document-settings.ts deleted file mode 100644 index 87a18d4e7..000000000 --- a/packages/lib/server-only/document/update-document-settings.ts +++ /dev/null @@ -1,281 +0,0 @@ -'use server'; - -import { match } from 'ts-pattern'; -import type { z } from 'zod'; - -import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; -import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; -import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; -import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs'; -import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; -import { prisma } from '@documenso/prisma'; -import { DocumentVisibility } from '@documenso/prisma/client'; -import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client'; -import { DocumentSchema } from '@documenso/prisma/generated/zod'; - -import { AppError, AppErrorCode } from '../../errors/app-error'; -import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; -import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth'; - -export type UpdateDocumentSettingsOptions = { - userId: number; - teamId?: number; - documentId: number; - data: { - title?: string; - externalId?: string | null; - visibility?: DocumentVisibility | null; - globalAccessAuth?: TDocumentAccessAuthTypes | null; - globalActionAuth?: TDocumentActionAuthTypes | null; - }; - requestMetadata?: RequestMetadata; -}; - -export const ZUpdateDocumentSettingsResponseSchema = DocumentSchema; - -export type TUpdateDocumentSettingsResponse = z.infer; - -export const updateDocumentSettings = async ({ - userId, - teamId, - documentId, - data, - requestMetadata, -}: UpdateDocumentSettingsOptions): Promise => { - if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) { - throw new AppError(AppErrorCode.INVALID_BODY, { - message: 'Missing data to update', - }); - } - - const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - }); - - const document = await prisma.document.findFirstOrThrow({ - where: { - id: documentId, - ...(teamId - ? { - team: { - id: teamId, - members: { - some: { - userId, - }, - }, - }, - } - : { - userId, - teamId: null, - }), - }, - include: { - team: { - select: { - members: { - where: { - userId, - }, - select: { - role: true, - }, - }, - }, - }, - }, - }); - - if (teamId) { - const currentUserRole = document.team?.members[0]?.role; - const isDocumentOwner = document.userId === userId; - const requestedVisibility = data.visibility; - - if (!isDocumentOwner) { - match(currentUserRole) - .with(TeamMemberRole.ADMIN, () => true) - .with(TeamMemberRole.MANAGER, () => { - const allowedVisibilities: DocumentVisibility[] = [ - DocumentVisibility.EVERYONE, - DocumentVisibility.MANAGER_AND_ABOVE, - ]; - - if ( - !allowedVisibilities.includes(document.visibility) || - (requestedVisibility && !allowedVisibilities.includes(requestedVisibility)) - ) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'You do not have permission to update the document visibility', - }); - } - }) - .with(TeamMemberRole.MEMBER, () => { - if ( - document.visibility !== DocumentVisibility.EVERYONE || - (requestedVisibility && requestedVisibility !== DocumentVisibility.EVERYONE) - ) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'You do not have permission to update the document visibility', - }); - } - }) - .otherwise(() => { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'You do not have permission to update the document', - }); - }); - } - } - - const { documentAuthOption } = extractDocumentAuthMethods({ - documentAuth: document.authOptions, - }); - - const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null; - const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null; - - // If the new global auth values aren't passed in, fallback to the current document values. - const newGlobalAccessAuth = - data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth; - const newGlobalActionAuth = - data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; - - // Check if user has permission to set the global action auth. - if (newGlobalActionAuth) { - const isDocumentEnterprise = await isUserEnterprise({ - userId, - teamId, - }); - - if (!isDocumentEnterprise) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'You do not have permission to set the action auth', - }); - } - } - - const isTitleSame = data.title === undefined || data.title === document.title; - const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId; - const isGlobalAccessSame = - documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth; - const isGlobalActionSame = - documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth; - const isDocumentVisibilitySame = - data.visibility === undefined || data.visibility === document.visibility; - - const auditLogs: CreateDocumentAuditLogDataResponse[] = []; - - if (!isTitleSame && document.status !== DocumentStatus.DRAFT) { - throw new AppError(AppErrorCode.INVALID_BODY, { - message: 'You cannot update the title if the document has been sent', - }); - } - - if (!isTitleSame) { - auditLogs.push( - createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED, - documentId, - user, - requestMetadata, - data: { - from: document.title, - to: data.title || '', - }, - }), - ); - } - - if (!isExternalIdSame) { - auditLogs.push( - createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED, - documentId, - user, - requestMetadata, - data: { - from: document.externalId, - to: data.externalId || '', - }, - }), - ); - } - - if (!isGlobalAccessSame) { - auditLogs.push( - createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED, - documentId, - user, - requestMetadata, - data: { - from: documentGlobalAccessAuth, - to: newGlobalAccessAuth, - }, - }), - ); - } - - if (!isGlobalActionSame) { - auditLogs.push( - createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED, - documentId, - user, - requestMetadata, - data: { - from: documentGlobalActionAuth, - to: newGlobalActionAuth, - }, - }), - ); - } - - if (!isDocumentVisibilitySame) { - auditLogs.push( - createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED, - documentId, - user, - requestMetadata, - data: { - from: document.visibility, - to: data.visibility || '', - }, - }), - ); - } - - // Early return if nothing is required. - if (auditLogs.length === 0) { - return document; - } - - return await prisma.$transaction(async (tx) => { - const authOptions = createDocumentAuthOptions({ - globalAccessAuth: newGlobalAccessAuth, - globalActionAuth: newGlobalActionAuth, - }); - - const updatedDocument = await tx.document.update({ - where: { - id: documentId, - }, - data: { - title: data.title, - externalId: data.externalId, - visibility: data.visibility as DocumentVisibility, - authOptions, - }, - }); - - await tx.documentAuditLog.createMany({ - data: auditLogs, - }); - - return updatedDocument; - }); -}; diff --git a/packages/lib/server-only/document/update-document.ts b/packages/lib/server-only/document/update-document.ts index 3e35f52e4..85c2bd73e 100644 --- a/packages/lib/server-only/document/update-document.ts +++ b/packages/lib/server-only/document/update-document.ts @@ -1,23 +1,46 @@ -'use server'; - -import type { Prisma } from '@prisma/client'; +import { match } from 'ts-pattern'; +import type { z } from 'zod'; +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs'; +import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; +import { DocumentVisibility } from '@documenso/prisma/client'; +import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client'; +import { DocumentSchema } from '@documenso/prisma/generated/zod'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; +import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth'; export type UpdateDocumentOptions = { - documentId: number; - data: Prisma.DocumentUpdateInput; userId: number; teamId?: number; + documentId: number; + data?: { + title?: string; + externalId?: string | null; + visibility?: DocumentVisibility | null; + globalAccessAuth?: TDocumentAccessAuthTypes | null; + globalActionAuth?: TDocumentActionAuthTypes | null; + }; + requestMetadata: ApiRequestMetadata; }; +export const ZUpdateDocumentResponseSchema = DocumentSchema; + +export type TUpdateDocumentResponse = z.infer; + export const updateDocument = async ({ - documentId, userId, teamId, + documentId, data, -}: UpdateDocumentOptions) => { - return await prisma.document.update({ + requestMetadata, +}: UpdateDocumentOptions): Promise => { + const document = await prisma.document.findFirst({ where: { id: documentId, ...(teamId @@ -36,8 +59,215 @@ export const updateDocument = async ({ teamId: null, }), }, - data: { - ...data, + include: { + team: { + select: { + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }, }, }); + + if (!document) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found', + }); + } + + if (teamId) { + const currentUserRole = document.team?.members[0]?.role; + const isDocumentOwner = document.userId === userId; + const requestedVisibility = data?.visibility; + + if (!isDocumentOwner) { + match(currentUserRole) + .with(TeamMemberRole.ADMIN, () => true) + .with(TeamMemberRole.MANAGER, () => { + const allowedVisibilities: DocumentVisibility[] = [ + DocumentVisibility.EVERYONE, + DocumentVisibility.MANAGER_AND_ABOVE, + ]; + + if ( + !allowedVisibilities.includes(document.visibility) || + (requestedVisibility && !allowedVisibilities.includes(requestedVisibility)) + ) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to update the document visibility', + }); + } + }) + .with(TeamMemberRole.MEMBER, () => { + if ( + document.visibility !== DocumentVisibility.EVERYONE || + (requestedVisibility && requestedVisibility !== DocumentVisibility.EVERYONE) + ) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to update the document visibility', + }); + } + }) + .otherwise(() => { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to update the document', + }); + }); + } + } + + // If no data just return the document since this function is normally chained after a meta update. + if (!data || Object.values(data).length === 0) { + return document; + } + + const { documentAuthOption } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + }); + + const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null; + const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null; + + // If the new global auth values aren't passed in, fallback to the current document values. + const newGlobalAccessAuth = + data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth; + const newGlobalActionAuth = + data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; + + // Check if user has permission to set the global action auth. + if (newGlobalActionAuth) { + const isDocumentEnterprise = await isUserEnterprise({ + userId, + teamId, + }); + + if (!isDocumentEnterprise) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to set the action auth', + }); + } + } + + const isTitleSame = data.title === undefined || data.title === document.title; + const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId; + const isGlobalAccessSame = + documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth; + const isGlobalActionSame = + documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth; + const isDocumentVisibilitySame = + data.visibility === undefined || data.visibility === document.visibility; + + const auditLogs: CreateDocumentAuditLogDataResponse[] = []; + + if (!isTitleSame && document.status !== DocumentStatus.DRAFT) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'You cannot update the title if the document has been sent', + }); + } + + if (!isTitleSame) { + auditLogs.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED, + documentId, + metadata: requestMetadata, + data: { + from: document.title, + to: data.title || '', + }, + }), + ); + } + + if (!isExternalIdSame) { + auditLogs.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED, + documentId, + metadata: requestMetadata, + data: { + from: document.externalId, + to: data.externalId || '', + }, + }), + ); + } + + if (!isGlobalAccessSame) { + auditLogs.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED, + documentId, + metadata: requestMetadata, + data: { + from: documentGlobalAccessAuth, + to: newGlobalAccessAuth, + }, + }), + ); + } + + if (!isGlobalActionSame) { + auditLogs.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED, + documentId, + metadata: requestMetadata, + data: { + from: documentGlobalActionAuth, + to: newGlobalActionAuth, + }, + }), + ); + } + + if (!isDocumentVisibilitySame) { + auditLogs.push( + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED, + documentId, + metadata: requestMetadata, + data: { + from: document.visibility, + to: data.visibility || '', + }, + }), + ); + } + + // Early return if nothing is required. + if (auditLogs.length === 0) { + return document; + } + + return await prisma.$transaction(async (tx) => { + const authOptions = createDocumentAuthOptions({ + globalAccessAuth: newGlobalAccessAuth, + globalActionAuth: newGlobalActionAuth, + }); + + const updatedDocument = await tx.document.update({ + where: { + id: documentId, + }, + data: { + title: data.title, + externalId: data.externalId, + visibility: data.visibility as DocumentVisibility, + authOptions, + }, + }); + + await tx.documentAuditLog.createMany({ + data: auditLogs, + }); + + return updatedDocument; + }); }; diff --git a/packages/lib/server-only/field/create-document-fields.ts b/packages/lib/server-only/field/create-document-fields.ts new file mode 100644 index 000000000..982e389e2 --- /dev/null +++ b/packages/lib/server-only/field/create-document-fields.ts @@ -0,0 +1,148 @@ +import { z } from 'zod'; + +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { prisma } from '@documenso/prisma'; +import type { FieldType } from '@documenso/prisma/client'; +import { FieldSchema } from '@documenso/prisma/generated/zod'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { canRecipientFieldsBeModified } from '../../utils/recipients'; + +export interface CreateDocumentFieldsOptions { + userId: number; + teamId?: number; + documentId: number; + fields: { + recipientId: number; + type: FieldType; + pageNumber: number; + pageX: number; + pageY: number; + width: number; + height: number; + fieldMeta?: TFieldMetaSchema; + }[]; + requestMetadata: ApiRequestMetadata; +} + +export const ZCreateDocumentFieldsResponseSchema = z.object({ + fields: z.array(FieldSchema), +}); + +export type TCreateDocumentFieldsResponse = z.infer; + +export const createDocumentFields = async ({ + userId, + teamId, + documentId, + fields, + requestMetadata, +}: CreateDocumentFieldsOptions): Promise => { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Recipient: true, + Field: true, + }, + }); + + if (!document) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found', + }); + } + + if (document.completedAt) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Document already complete', + }); + } + + // Field validation. + const validatedFields = fields.map((field) => { + const recipient = document.Recipient.find((recipient) => recipient.id === field.recipientId); + + // Each field MUST have a recipient associated with it. + if (!recipient) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `Recipient ${field.recipientId} not found`, + }); + } + + // Check whether the recipient associated with the field can have new fields created. + if (!canRecipientFieldsBeModified(recipient, document.Field)) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: + 'Recipient type cannot have fields, or they have already interacted with the document.', + }); + } + + return { + ...field, + recipientEmail: recipient.email, + }; + }); + + const createdFields = await prisma.$transaction(async (tx) => { + return await Promise.all( + validatedFields.map(async (field) => { + const createdField = await tx.field.create({ + data: { + type: field.type, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.width, + height: field.height, + customText: '', + inserted: false, + fieldMeta: field.fieldMeta, + documentId, + recipientId: field.recipientId, + }, + }); + + // Handle field created audit log. + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED, + documentId, + metadata: requestMetadata, + data: { + fieldId: createdField.secondaryId, + fieldRecipientEmail: field.recipientEmail, + fieldRecipientId: createdField.recipientId, + fieldType: createdField.type, + }, + }), + }); + + return createdField; + }), + ); + }); + + return { + fields: createdFields, + }; +}; diff --git a/packages/lib/server-only/field/create-template-fields.ts b/packages/lib/server-only/field/create-template-fields.ts new file mode 100644 index 000000000..fd93ed2be --- /dev/null +++ b/packages/lib/server-only/field/create-template-fields.ts @@ -0,0 +1,122 @@ +import { z } from 'zod'; + +import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta'; +import { prisma } from '@documenso/prisma'; +import type { FieldType } from '@documenso/prisma/client'; +import { FieldSchema } from '@documenso/prisma/generated/zod'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { canRecipientFieldsBeModified } from '../../utils/recipients'; + +export interface CreateTemplateFieldsOptions { + userId: number; + teamId?: number; + templateId: number; + fields: { + recipientId: number; + type: FieldType; + pageNumber: number; + pageX: number; + pageY: number; + width: number; + height: number; + fieldMeta?: TFieldMetaSchema; + }[]; +} + +export const ZCreateTemplateFieldsResponseSchema = z.object({ + fields: z.array(FieldSchema), +}); + +export type TCreateTemplateFieldsResponse = z.infer; + +export const createTemplateFields = async ({ + userId, + teamId, + templateId, + fields, +}: CreateTemplateFieldsOptions): Promise => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Recipient: true, + Field: true, + }, + }); + + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'template not found', + }); + } + + // Field validation. + const validatedFields = fields.map((field) => { + const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId); + + // Each field MUST have a recipient associated with it. + if (!recipient) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `Recipient ${field.recipientId} not found`, + }); + } + + // Check whether the recipient associated with the field can have new fields created. + if (!canRecipientFieldsBeModified(recipient, template.Field)) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: + 'Recipient type cannot have fields, or they have already interacted with the template.', + }); + } + + return { + ...field, + recipientEmail: recipient.email, + }; + }); + + const createdFields = await prisma.$transaction(async (tx) => { + return await Promise.all( + validatedFields.map(async (field) => { + const createdField = await tx.field.create({ + data: { + type: field.type, + page: field.pageNumber, + positionX: field.pageX, + positionY: field.pageY, + width: field.width, + height: field.height, + customText: '', + inserted: false, + fieldMeta: field.fieldMeta, + templateId, + recipientId: field.recipientId, + }, + }); + + return createdField; + }), + ); + }); + + return { + fields: createdFields, + }; +}; diff --git a/packages/lib/server-only/field/delete-document-field.ts b/packages/lib/server-only/field/delete-document-field.ts new file mode 100644 index 000000000..28f739a5f --- /dev/null +++ b/packages/lib/server-only/field/delete-document-field.ts @@ -0,0 +1,122 @@ +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { prisma } from '@documenso/prisma'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { canRecipientFieldsBeModified } from '../../utils/recipients'; + +export interface DeleteDocumentFieldOptions { + userId: number; + teamId?: number; + fieldId: number; + requestMetadata: ApiRequestMetadata; +} + +export const deleteDocumentField = async ({ + userId, + teamId, + fieldId, + requestMetadata, +}: DeleteDocumentFieldOptions): Promise => { + const field = await prisma.field.findFirst({ + where: { + id: fieldId, + }, + }); + + if (!field) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Field not found', + }); + } + + const documentId = field.documentId; + + if (!documentId) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Field does not belong to a document. Use delete template field instead.', + }); + } + + const document = await prisma.document.findFirst({ + where: { + id: documentId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Recipient: { + where: { + id: field.recipientId, + }, + include: { + Field: true, + }, + }, + }, + }); + + if (!document) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found', + }); + } + + if (document.completedAt) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Document already complete', + }); + } + + const recipient = document.Recipient.find((recipient) => recipient.id === field.recipientId); + + if (!recipient) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `Recipient for field ${fieldId} not found`, + }); + } + + // Check whether the recipient associated with the field can have new fields created. + if (!canRecipientFieldsBeModified(recipient, recipient.Field)) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Recipient has already interacted with the document.', + }); + } + + await prisma.$transaction(async (tx) => { + const deletedField = await tx.field.delete({ + where: { + id: fieldId, + }, + }); + + // Handle field deleted audit log. + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED, + documentId, + metadata: requestMetadata, + data: { + fieldId: deletedField.secondaryId, + fieldRecipientEmail: recipient.email, + fieldRecipientId: deletedField.recipientId, + fieldType: deletedField.type, + }, + }), + }); + }); +}; diff --git a/packages/lib/server-only/field/delete-template-field.ts b/packages/lib/server-only/field/delete-template-field.ts new file mode 100644 index 000000000..80e140a18 --- /dev/null +++ b/packages/lib/server-only/field/delete-template-field.ts @@ -0,0 +1,48 @@ +import { prisma } from '@documenso/prisma'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export interface DeleteTemplateFieldOptions { + userId: number; + teamId?: number; + fieldId: number; +} + +export const deleteTemplateField = async ({ + userId, + teamId, + fieldId, +}: DeleteTemplateFieldOptions): Promise => { + const field = await prisma.field.findFirst({ + where: { + id: fieldId, + Template: teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }, + }, + }); + + if (!field || !field.templateId) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Field not found', + }); + } + + await prisma.field.delete({ + where: { + id: fieldId, + }, + }); +}; diff --git a/packages/lib/server-only/field/get-fields-for-document.ts b/packages/lib/server-only/field/get-fields-for-document.ts index 2cd8aa07c..74b6b4133 100644 --- a/packages/lib/server-only/field/get-fields-for-document.ts +++ b/packages/lib/server-only/field/get-fields-for-document.ts @@ -3,30 +3,34 @@ import { prisma } from '@documenso/prisma'; export interface GetFieldsForDocumentOptions { documentId: number; userId: number; + teamId?: number; } export type DocumentField = Awaited>[number]; -export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForDocumentOptions) => { +export const getFieldsForDocument = async ({ + documentId, + userId, + teamId, +}: GetFieldsForDocumentOptions) => { const fields = await prisma.field.findMany({ where: { documentId, - Document: { - OR: [ - { - userId, - }, - { + Document: teamId + ? { team: { + id: teamId, members: { some: { userId, }, }, }, + } + : { + userId, + teamId: null, }, - ], - }, }, include: { Signature: true, diff --git a/packages/lib/server-only/field/get-fields-for-template.ts b/packages/lib/server-only/field/get-fields-for-template.ts deleted file mode 100644 index 724ec75fb..000000000 --- a/packages/lib/server-only/field/get-fields-for-template.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { prisma } from '@documenso/prisma'; - -export interface GetFieldsForTemplateOptions { - templateId: number; - userId: number; -} - -export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForTemplateOptions) => { - const fields = await prisma.field.findMany({ - where: { - templateId, - Template: { - OR: [ - { - userId, - }, - { - team: { - members: { - some: { - userId, - }, - }, - }, - }, - ], - }, - }, - orderBy: { - id: 'asc', - }, - }); - - return fields; -}; diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index 6b3a79bf2..9b5694d22 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -16,7 +16,7 @@ import { ZRadioFieldMeta, ZTextFieldMeta, } from '@documenso/lib/types/field-meta'; -import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { createDocumentAuditLogData, diffFieldChanges, @@ -31,9 +31,10 @@ import { canRecipientFieldsBeModified } from '../../utils/recipients'; export interface SetFieldsForDocumentOptions { userId: number; + teamId?: number; documentId: number; fields: FieldData[]; - requestMetadata?: RequestMetadata; + requestMetadata: ApiRequestMetadata; } export const ZSetFieldsForDocumentResponseSchema = z.object({ @@ -44,6 +45,7 @@ export type TSetFieldsForDocumentResponse = z.infer => { const template = await prisma.template.findFirst({ where: { id: templateId, - OR: [ - { - userId, - }, - { - team: { - members: { - some: { - userId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, }, }, - }, - }, - ], + } + : { + userId, + teamId: null, + }), }, }); diff --git a/packages/lib/server-only/field/update-document-fields.ts b/packages/lib/server-only/field/update-document-fields.ts new file mode 100644 index 000000000..1f1c80461 --- /dev/null +++ b/packages/lib/server-only/field/update-document-fields.ts @@ -0,0 +1,165 @@ +import { z } from 'zod'; + +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { + createDocumentAuditLogData, + diffFieldChanges, +} from '@documenso/lib/utils/document-audit-logs'; +import { prisma } from '@documenso/prisma'; +import type { FieldType } from '@documenso/prisma/client'; +import { FieldSchema } from '@documenso/prisma/generated/zod'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { canRecipientFieldsBeModified } from '../../utils/recipients'; + +export interface UpdateDocumentFieldsOptions { + userId: number; + teamId?: number; + documentId: number; + fields: { + id: number; + type?: FieldType; + pageNumber?: number; + pageX?: number; + pageY?: number; + width?: number; + height?: number; + fieldMeta?: TFieldMetaSchema; + }[]; + requestMetadata: ApiRequestMetadata; +} + +export const ZUpdateDocumentFieldsResponseSchema = z.object({ + fields: z.array(FieldSchema), +}); + +export type TUpdateDocumentFieldsResponse = z.infer; + +export const updateDocumentFields = async ({ + userId, + teamId, + documentId, + fields, + requestMetadata, +}: UpdateDocumentFieldsOptions): Promise => { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Recipient: true, + Field: true, + }, + }); + + if (!document) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found', + }); + } + + if (document.completedAt) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Document already complete', + }); + } + + const fieldsToUpdate = fields.map((field) => { + const originalField = document.Field.find((existingField) => existingField.id === field.id); + + if (!originalField) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: `Field with id ${field.id} not found`, + }); + } + + const recipient = document.Recipient.find( + (recipient) => recipient.id === originalField.recipientId, + ); + + // Each field MUST have a recipient associated with it. + if (!recipient) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `Recipient attached to field ${field.id} not found`, + }); + } + + // Check whether the recipient associated with the field can be modified. + if (!canRecipientFieldsBeModified(recipient, document.Field)) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: + 'Cannot modify a field where the recipient has already interacted with the document', + }); + } + + return { + originalField, + updateData: field, + recipientEmail: recipient.email, + }; + }); + + const updatedFields = await prisma.$transaction(async (tx) => { + return await Promise.all( + fieldsToUpdate.map(async ({ originalField, updateData, recipientEmail }) => { + const updatedField = await tx.field.update({ + where: { + id: updateData.id, + }, + data: { + type: updateData.type, + page: updateData.pageNumber, + positionX: updateData.pageX, + positionY: updateData.pageY, + width: updateData.width, + height: updateData.height, + fieldMeta: updateData.fieldMeta, + }, + }); + + const changes = diffFieldChanges(originalField, updatedField); + + // Handle field updated audit log. + if (changes.length > 0) { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED, + documentId: documentId, + metadata: requestMetadata, + data: { + fieldId: updatedField.secondaryId, + fieldRecipientEmail: recipientEmail, + fieldRecipientId: updatedField.recipientId, + fieldType: updatedField.type, + changes, + }, + }), + }); + } + + return updatedField; + }), + ); + }); + + return { + fields: updatedFields, + }; +}; diff --git a/packages/lib/server-only/field/update-template-fields.ts b/packages/lib/server-only/field/update-template-fields.ts new file mode 100644 index 000000000..f07f5c92e --- /dev/null +++ b/packages/lib/server-only/field/update-template-fields.ts @@ -0,0 +1,129 @@ +import { z } from 'zod'; + +import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta'; +import { prisma } from '@documenso/prisma'; +import type { FieldType } from '@documenso/prisma/client'; +import { FieldSchema } from '@documenso/prisma/generated/zod'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { canRecipientFieldsBeModified } from '../../utils/recipients'; + +export interface UpdateTemplateFieldsOptions { + userId: number; + teamId?: number; + templateId: number; + fields: { + id: number; + type?: FieldType; + pageNumber?: number; + pageX?: number; + pageY?: number; + width?: number; + height?: number; + fieldMeta?: TFieldMetaSchema; + }[]; +} + +export const ZUpdateTemplateFieldsResponseSchema = z.object({ + fields: z.array(FieldSchema), +}); + +export type TUpdateTemplateFieldsResponse = z.infer; + +export const updateTemplateFields = async ({ + userId, + teamId, + templateId, + fields, +}: UpdateTemplateFieldsOptions): Promise => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Recipient: true, + Field: true, + }, + }); + + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found', + }); + } + + const fieldsToUpdate = fields.map((field) => { + const originalField = template.Field.find((existingField) => existingField.id === field.id); + + if (!originalField) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: `Field with id ${field.id} not found`, + }); + } + + const recipient = template.Recipient.find( + (recipient) => recipient.id === originalField.recipientId, + ); + + // Each field MUST have a recipient associated with it. + if (!recipient) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `Recipient attached to field ${field.id} not found`, + }); + } + + // Check whether the recipient associated with the field can be modified. + if (!canRecipientFieldsBeModified(recipient, template.Field)) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: + 'Cannot modify a field where the recipient has already interacted with the document', + }); + } + + return { + updateData: field, + }; + }); + + const updatedFields = await prisma.$transaction(async (tx) => { + return await Promise.all( + fieldsToUpdate.map(async ({ updateData }) => { + const updatedField = await tx.field.update({ + where: { + id: updateData.id, + }, + data: { + type: updateData.type, + page: updateData.pageNumber, + positionX: updateData.pageX, + positionY: updateData.pageY, + width: updateData.width, + height: updateData.height, + fieldMeta: updateData.fieldMeta, + }, + }); + + return updatedField; + }), + ); + }); + + return { + fields: updatedFields, + }; +}; diff --git a/packages/lib/server-only/profile/set-avatar-image.ts b/packages/lib/server-only/profile/set-avatar-image.ts index 92683dcc4..1bb63d824 100644 --- a/packages/lib/server-only/profile/set-avatar-image.ts +++ b/packages/lib/server-only/profile/set-avatar-image.ts @@ -3,13 +3,13 @@ import sharp from 'sharp'; import { prisma } from '@documenso/prisma'; import { AppError, AppErrorCode } from '../../errors/app-error'; -import type { RequestMetadata } from '../../universal/extract-request-metadata'; +import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; export type SetAvatarImageOptions = { userId: number; teamId?: number | null; bytes?: string | null; - requestMetadata?: RequestMetadata; + requestMetadata: ApiRequestMetadata; }; export const setAvatarImage = async ({ diff --git a/packages/lib/server-only/recipient/create-document-recipients.ts b/packages/lib/server-only/recipient/create-document-recipients.ts new file mode 100644 index 000000000..471e4d94a --- /dev/null +++ b/packages/lib/server-only/recipient/create-document-recipients.ts @@ -0,0 +1,167 @@ +import { z } from 'zod'; + +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth'; +import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { nanoid } from '@documenso/lib/universal/id'; +import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth'; +import { prisma } from '@documenso/prisma'; +import { RecipientRole } from '@documenso/prisma/client'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { ZRecipientBaseResponseSchema } from '@documenso/trpc/server/recipient-router/schema'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export interface CreateDocumentRecipientsOptions { + userId: number; + teamId?: number; + documentId: number; + recipients: { + email: string; + name: string; + role: RecipientRole; + signingOrder?: number | null; + accessAuth?: TRecipientAccessAuthTypes | null; + actionAuth?: TRecipientActionAuthTypes | null; + }[]; + requestMetadata: ApiRequestMetadata; +} + +export const ZCreateDocumentRecipientsResponseSchema = z.object({ + recipients: ZRecipientBaseResponseSchema.array(), +}); + +export type TCreateDocumentRecipientsResponse = z.infer< + typeof ZCreateDocumentRecipientsResponseSchema +>; + +export const createDocumentRecipients = async ({ + userId, + teamId, + documentId, + recipients: recipientsToCreate, + requestMetadata, +}: CreateDocumentRecipientsOptions): Promise => { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Recipient: true, + }, + }); + + if (!document) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found', + }); + } + + if (document.completedAt) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Document already complete', + }); + } + + const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth); + + // Check if user has permission to set the global action auth. + if (recipientsHaveActionAuth) { + const isEnterprise = await isUserEnterprise({ + userId, + teamId, + }); + + if (!isEnterprise) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to set the action auth', + }); + } + } + + const normalizedRecipients = recipientsToCreate.map((recipient) => ({ + ...recipient, + email: recipient.email.toLowerCase(), + })); + + const duplicateRecipients = normalizedRecipients.filter((newRecipient) => { + const existingRecipient = document.Recipient.find( + (existingRecipient) => existingRecipient.email === newRecipient.email, + ); + + return existingRecipient !== undefined; + }); + + if (duplicateRecipients.length > 0) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`, + }); + } + + const createdRecipients = await prisma.$transaction(async (tx) => { + return await Promise.all( + normalizedRecipients.map(async (recipient) => { + const authOptions = createRecipientAuthOptions({ + accessAuth: recipient.accessAuth || null, + actionAuth: recipient.actionAuth || null, + }); + + const createdRecipient = await tx.recipient.create({ + data: { + documentId, + name: recipient.name, + email: recipient.email, + role: recipient.role, + signingOrder: recipient.signingOrder, + token: nanoid(), + sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, + authOptions, + }, + }); + + // Handle recipient created audit log. + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED, + documentId: documentId, + metadata: requestMetadata, + data: { + recipientEmail: createdRecipient.email, + recipientName: createdRecipient.name, + recipientId: createdRecipient.id, + recipientRole: createdRecipient.role, + accessAuth: recipient.accessAuth || undefined, + actionAuth: recipient.actionAuth || undefined, + }, + }), + }); + + return createdRecipient; + }), + ); + }); + + return { + recipients: createdRecipients, + }; +}; diff --git a/packages/lib/server-only/recipient/create-template-recipients.ts b/packages/lib/server-only/recipient/create-template-recipients.ts new file mode 100644 index 000000000..d1e4da6d0 --- /dev/null +++ b/packages/lib/server-only/recipient/create-template-recipients.ts @@ -0,0 +1,139 @@ +import { z } from 'zod'; + +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth'; +import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth'; +import { nanoid } from '@documenso/lib/universal/id'; +import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth'; +import { prisma } from '@documenso/prisma'; +import { RecipientRole } from '@documenso/prisma/client'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { ZRecipientBaseResponseSchema } from '@documenso/trpc/server/recipient-router/schema'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export interface CreateTemplateRecipientsOptions { + userId: number; + teamId?: number; + templateId: number; + recipients: { + email: string; + name: string; + role: RecipientRole; + signingOrder?: number | null; + accessAuth?: TRecipientAccessAuthTypes | null; + actionAuth?: TRecipientActionAuthTypes | null; + }[]; +} + +export const ZCreateTemplateRecipientsResponseSchema = z.object({ + recipients: ZRecipientBaseResponseSchema.array(), +}); + +export type TCreateTemplateRecipientsResponse = z.infer< + typeof ZCreateTemplateRecipientsResponseSchema +>; + +export const createTemplateRecipients = async ({ + userId, + teamId, + templateId, + recipients: recipientsToCreate, +}: CreateTemplateRecipientsOptions): Promise => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Recipient: true, + }, + }); + + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Template not found', + }); + } + + const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth); + + // Check if user has permission to set the global action auth. + if (recipientsHaveActionAuth) { + const isEnterprise = await isUserEnterprise({ + userId, + teamId, + }); + + if (!isEnterprise) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to set the action auth', + }); + } + } + + const normalizedRecipients = recipientsToCreate.map((recipient) => ({ + ...recipient, + email: recipient.email.toLowerCase(), + })); + + const duplicateRecipients = normalizedRecipients.filter((newRecipient) => { + const existingRecipient = template.Recipient.find( + (existingRecipient) => existingRecipient.email === newRecipient.email, + ); + + return existingRecipient !== undefined; + }); + + if (duplicateRecipients.length > 0) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `Duplicate recipient(s) found for ${duplicateRecipients.map((recipient) => recipient.email).join(', ')}`, + }); + } + + const createdRecipients = await prisma.$transaction(async (tx) => { + return await Promise.all( + normalizedRecipients.map(async (recipient) => { + const authOptions = createRecipientAuthOptions({ + accessAuth: recipient.accessAuth || null, + actionAuth: recipient.actionAuth || null, + }); + + const createdRecipient = await tx.recipient.create({ + data: { + templateId, + name: recipient.name, + email: recipient.email, + role: recipient.role, + signingOrder: recipient.signingOrder, + token: nanoid(), + sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, + authOptions, + }, + }); + + return createdRecipient; + }), + ); + }); + + return { + recipients: createdRecipients, + }; +}; diff --git a/packages/lib/server-only/recipient/delete-document-recipient.ts b/packages/lib/server-only/recipient/delete-document-recipient.ts new file mode 100644 index 000000000..1171951f8 --- /dev/null +++ b/packages/lib/server-only/recipient/delete-document-recipient.ts @@ -0,0 +1,161 @@ +import { createElement } from 'react'; + +import { msg } from '@lingui/macro'; + +import { mailer } from '@documenso/email/mailer'; +import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { prisma } from '@documenso/prisma'; +import { SendStatus } from '@documenso/prisma/client'; + +import { getI18nInstance } from '../../client-only/providers/i18n.server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; +import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; + +export interface DeleteDocumentRecipientOptions { + userId: number; + teamId?: number; + recipientId: number; + requestMetadata: ApiRequestMetadata; +} + +export const deleteDocumentRecipient = async ({ + userId, + teamId, + recipientId, + requestMetadata, +}: DeleteDocumentRecipientOptions): Promise => { + const document = await prisma.document.findFirst({ + where: { + Recipient: { + some: { + id: recipientId, + }, + }, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + documentMeta: true, + team: true, + Recipient: { + where: { + id: recipientId, + }, + }, + }, + }); + + const user = await prisma.user.findFirst({ + where: { + id: userId, + }, + select: { + id: true, + name: true, + email: true, + }, + }); + + if (!document) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found', + }); + } + + if (document.completedAt) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Document already complete', + }); + } + + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'User not found', + }); + } + + const recipientToDelete = document.Recipient[0]; + + if (!recipientToDelete || recipientToDelete.id !== recipientId) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Recipient not found', + }); + } + + await prisma.$transaction(async (tx) => { + await tx.recipient.delete({ + where: { + id: recipientId, + }, + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED, + documentId: document.id, + metadata: requestMetadata, + data: { + recipientEmail: recipientToDelete.email, + recipientName: recipientToDelete.name, + recipientId: recipientToDelete.id, + recipientRole: recipientToDelete.role, + }, + }), + }); + }); + + const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings( + document.documentMeta, + ).recipientRemoved; + + // Send email to deleted recipient. + if (recipientToDelete.sendStatus === SendStatus.SENT && isRecipientRemovedEmailEnabled) { + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + + const template = createElement(RecipientRemovedFromDocumentTemplate, { + documentName: document.title, + inviterName: document.team?.name || user.name || undefined, + assetBaseUrl, + }); + + const [html, text] = await Promise.all([ + renderEmailWithI18N(template, { lang: document.documentMeta?.language }), + renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }), + ]); + + const i18n = await getI18nInstance(document.documentMeta?.language); + + await mailer.sendMail({ + to: { + address: recipientToDelete.email, + name: recipientToDelete.name, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: i18n._(msg`You have been removed from a document`), + html, + text, + }); + } +}; diff --git a/packages/lib/server-only/recipient/delete-template-recipient.ts b/packages/lib/server-only/recipient/delete-template-recipient.ts new file mode 100644 index 000000000..8d101caa1 --- /dev/null +++ b/packages/lib/server-only/recipient/delete-template-recipient.ts @@ -0,0 +1,67 @@ +import { prisma } from '@documenso/prisma'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export interface DeleteTemplateRecipientOptions { + userId: number; + teamId?: number; + recipientId: number; +} + +export const deleteTemplateRecipient = async ({ + userId, + teamId, + recipientId, +}: DeleteTemplateRecipientOptions): Promise => { + const template = await prisma.template.findFirst({ + where: { + Recipient: { + some: { + id: recipientId, + }, + }, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Recipient: { + where: { + id: recipientId, + }, + }, + }, + }); + + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Template not found', + }); + } + + const recipientToDelete = template.Recipient[0]; + + if (!recipientToDelete || recipientToDelete.id !== recipientId) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Recipient not found', + }); + } + + await prisma.recipient.delete({ + where: { + id: recipientId, + }, + }); +}; diff --git a/packages/lib/server-only/recipient/get-recipient-by-id.ts b/packages/lib/server-only/recipient/get-recipient-by-id.ts index 52b98019f..07240b58f 100644 --- a/packages/lib/server-only/recipient/get-recipient-by-id.ts +++ b/packages/lib/server-only/recipient/get-recipient-by-id.ts @@ -29,25 +29,21 @@ export const getRecipientById = async ({ const recipient = await prisma.recipient.findFirst({ where: { id: recipientId, - Document: { - OR: [ - teamId === undefined - ? { - userId, - teamId: null, - } - : { - teamId, - team: { - members: { - some: { - userId, - }, - }, + Document: teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, }, }, - ], - }, + }, + } + : { + userId, + teamId: null, + }, }, include: { Field: true, diff --git a/packages/lib/server-only/recipient/get-recipients-for-document.ts b/packages/lib/server-only/recipient/get-recipients-for-document.ts index 03bc0e6c8..ea038f29e 100644 --- a/packages/lib/server-only/recipient/get-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/get-recipients-for-document.ts @@ -14,23 +14,21 @@ export const getRecipientsForDocument = async ({ const recipients = await prisma.recipient.findMany({ where: { documentId, - Document: { - OR: [ - { - userId, - }, - { - teamId, + Document: teamId + ? { team: { + id: teamId, members: { some: { userId, }, }, }, + } + : { + userId, + teamId: null, }, - ], - }, }, orderBy: { id: 'asc', 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 4b393353d..d13fbf238 100644 --- a/packages/lib/server-only/recipient/get-recipients-for-template.ts +++ b/packages/lib/server-only/recipient/get-recipients-for-template.ts @@ -3,31 +3,32 @@ import { prisma } from '@documenso/prisma'; export interface GetRecipientsForTemplateOptions { templateId: number; userId: number; + teamId?: number; } export const getRecipientsForTemplate = async ({ templateId, userId, + teamId, }: GetRecipientsForTemplateOptions) => { const recipients = await prisma.recipient.findMany({ where: { templateId, - Template: { - OR: [ - { - userId, - }, - { + Template: teamId + ? { team: { + id: teamId, members: { some: { userId, }, }, }, + } + : { + userId, + teamId: null, }, - ], - }, }, orderBy: { id: 'asc', diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-document-recipients.ts similarity index 91% rename from packages/lib/server-only/recipient/set-recipients-for-document.ts rename to packages/lib/server-only/recipient/set-document-recipients.ts index fb1a50997..7d260ef46 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -7,11 +7,12 @@ import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-ent import { mailer } from '@documenso/email/mailer'; import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth'; import { type TRecipientActionAuthTypes, ZRecipientAuthOptionsSchema, } from '@documenso/lib/types/document-auth'; -import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { nanoid } from '@documenso/lib/universal/id'; import { createDocumentAuditLogData, @@ -33,29 +34,27 @@ import { canRecipientBeModified } from '../../utils/recipients'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding'; -export interface SetRecipientsForDocumentOptions { +export interface SetDocumentRecipientsOptions { userId: number; teamId?: number; documentId: number; recipients: RecipientData[]; - requestMetadata?: RequestMetadata; + requestMetadata: ApiRequestMetadata; } -export const ZSetRecipientsForDocumentResponseSchema = z.object({ +export const ZSetDocumentRecipientsResponseSchema = z.object({ recipients: RecipientSchema.array(), }); -export type TSetRecipientsForDocumentResponse = z.infer< - typeof ZSetRecipientsForDocumentResponseSchema ->; +export type TSetDocumentRecipientsResponse = z.infer; -export const setRecipientsForDocument = async ({ +export const setDocumentRecipients = async ({ userId, teamId, documentId, recipients, requestMetadata, -}: SetRecipientsForDocumentOptions): Promise => { +}: SetDocumentRecipientsOptions): Promise => { const document = await prisma.document.findFirst({ where: { id: documentId, @@ -167,10 +166,10 @@ export const setRecipientsForDocument = async ({ linkedRecipients.map(async (recipient) => { let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions); - if (recipient.actionAuth !== undefined) { + if (recipient.actionAuth !== undefined || recipient.accessAuth !== undefined) { authOptions = createRecipientAuthOptions({ - accessAuth: authOptions.accessAuth, - actionAuth: recipient.actionAuth, + accessAuth: recipient.accessAuth || authOptions.accessAuth, + actionAuth: recipient.actionAuth || authOptions.actionAuth, }); } @@ -236,8 +235,7 @@ export const setRecipientsForDocument = async ({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, documentId: documentId, - user, - requestMetadata, + metadata: requestMetadata, data: { changes, ...baseAuditLog, @@ -252,10 +250,10 @@ export const setRecipientsForDocument = async ({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED, documentId: documentId, - user, - requestMetadata, + metadata: requestMetadata, data: { ...baseAuditLog, + accessAuth: recipient.accessAuth || undefined, actionAuth: recipient.actionAuth || undefined, }, }), @@ -282,8 +280,7 @@ export const setRecipientsForDocument = async ({ createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED, documentId: documentId, - user, - requestMetadata, + metadata: requestMetadata, data: { recipientEmail: recipient.email, recipientName: recipient.name, @@ -368,6 +365,7 @@ type RecipientData = { name: string; role: RecipientRole; signingOrder?: number | null; + accessAuth?: TRecipientAccessAuthTypes | null; actionAuth?: TRecipientActionAuthTypes | null; }; @@ -379,6 +377,7 @@ const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: Recipie recipient.name !== newRecipientData.name || recipient.role !== newRecipientData.role || recipient.signingOrder !== newRecipientData.signingOrder || + authOptions.accessAuth !== newRecipientData.accessAuth || authOptions.actionAuth !== newRecipientData.actionAuth ); }; diff --git a/packages/lib/server-only/recipient/set-recipients-for-template.ts b/packages/lib/server-only/recipient/set-template-recipients.ts similarity index 91% rename from packages/lib/server-only/recipient/set-recipients-for-template.ts rename to packages/lib/server-only/recipient/set-template-recipients.ts index 82859ca73..4de8683a8 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-template.ts +++ b/packages/lib/server-only/recipient/set-template-recipients.ts @@ -18,7 +18,7 @@ import { import { nanoid } from '../../universal/id'; import { createRecipientAuthOptions } from '../../utils/document-auth'; -export type SetRecipientsForTemplateOptions = { +export type SetTemplateRecipientsOptions = { userId: number; teamId?: number; templateId: number; @@ -32,37 +32,36 @@ export type SetRecipientsForTemplateOptions = { }[]; }; -export const ZSetRecipientsForTemplateResponseSchema = z.object({ +export const ZSetTemplateRecipientsResponseSchema = z.object({ recipients: RecipientSchema.array(), }); -export type TSetRecipientsForTemplateResponse = z.infer< - typeof ZSetRecipientsForTemplateResponseSchema ->; +export type TSetTemplateRecipientsResponse = z.infer; -export const setRecipientsForTemplate = async ({ +export const setTemplateRecipients = async ({ userId, teamId, templateId, recipients, -}: SetRecipientsForTemplateOptions): Promise => { +}: SetTemplateRecipientsOptions): Promise => { const template = await prisma.template.findFirst({ where: { id: templateId, - OR: [ - { - userId, - }, - { - team: { - members: { - some: { - userId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, }, }, - }, - }, - ], + } + : { + userId, + teamId: null, + }), }, include: { directLink: true, diff --git a/packages/lib/server-only/recipient/update-document-recipients.ts b/packages/lib/server-only/recipient/update-document-recipients.ts new file mode 100644 index 000000000..d6dfae1ed --- /dev/null +++ b/packages/lib/server-only/recipient/update-document-recipients.ts @@ -0,0 +1,246 @@ +import { z } from 'zod'; + +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; +import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth'; +import { + type TRecipientActionAuthTypes, + ZRecipientAuthOptionsSchema, +} from '@documenso/lib/types/document-auth'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { + createDocumentAuditLogData, + diffRecipientChanges, +} from '@documenso/lib/utils/document-audit-logs'; +import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth'; +import { prisma } from '@documenso/prisma'; +import type { Recipient } from '@documenso/prisma/client'; +import { RecipientRole } from '@documenso/prisma/client'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { ZRecipientResponseSchema } from '@documenso/trpc/server/recipient-router/schema'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { canRecipientBeModified } from '../../utils/recipients'; + +export interface UpdateDocumentRecipientsOptions { + userId: number; + teamId?: number; + documentId: number; + recipients: RecipientData[]; + requestMetadata: ApiRequestMetadata; +} + +export const ZUpdateDocumentRecipientsResponseSchema = z.object({ + recipients: ZRecipientResponseSchema.array(), +}); + +export type TUpdateDocumentRecipientsResponse = z.infer< + typeof ZUpdateDocumentRecipientsResponseSchema +>; + +export const updateDocumentRecipients = async ({ + userId, + teamId, + documentId, + recipients, + requestMetadata, +}: UpdateDocumentRecipientsOptions): Promise => { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Field: true, + Recipient: true, + }, + }); + + if (!document) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found', + }); + } + + if (document.completedAt) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Document already complete', + }); + } + + const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + + // Check if user has permission to set the global action auth. + if (recipientsHaveActionAuth) { + const isEnterprise = await isUserEnterprise({ + userId, + teamId, + }); + + if (!isEnterprise) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to set the action auth', + }); + } + } + + const recipientsToUpdate = recipients.map((recipient) => { + const originalRecipient = document.Recipient.find( + (existingRecipient) => existingRecipient.id === recipient.id, + ); + + if (!originalRecipient) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: `Recipient with id ${recipient.id} not found`, + }); + } + + const duplicateRecipientWithSameEmail = document.Recipient.find( + (existingRecipient) => + existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id, + ); + + if (duplicateRecipientWithSameEmail) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`, + }); + } + + if ( + hasRecipientBeenChanged(originalRecipient, recipient) && + !canRecipientBeModified(originalRecipient, document.Field) + ) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Cannot modify a recipient who has already interacted with the document', + }); + } + + return { + originalRecipient, + updateData: recipient, + }; + }); + + const updatedRecipients = await prisma.$transaction(async (tx) => { + return await Promise.all( + recipientsToUpdate.map(async ({ originalRecipient, updateData }) => { + let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions); + + if (updateData.actionAuth !== undefined || updateData.accessAuth !== undefined) { + authOptions = createRecipientAuthOptions({ + accessAuth: updateData.accessAuth || authOptions.accessAuth, + actionAuth: updateData.actionAuth || authOptions.actionAuth, + }); + } + + const mergedRecipient = { + ...originalRecipient, + ...updateData, + }; + + const updatedRecipient = await tx.recipient.update({ + where: { + id: originalRecipient.id, + documentId, + }, + data: { + name: mergedRecipient.name, + email: mergedRecipient.email, + role: mergedRecipient.role, + signingOrder: mergedRecipient.signingOrder, + documentId, + sendStatus: + mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + mergedRecipient.role === RecipientRole.CC + ? SigningStatus.SIGNED + : SigningStatus.NOT_SIGNED, + authOptions, + }, + include: { + Field: true, + }, + }); + + // Clear all fields if the recipient role is changed to a type that cannot have fields. + if ( + originalRecipient.role !== updatedRecipient.role && + (updatedRecipient.role === RecipientRole.CC || + updatedRecipient.role === RecipientRole.VIEWER) + ) { + await tx.field.deleteMany({ + where: { + recipientId: updatedRecipient.id, + }, + }); + } + + const changes = diffRecipientChanges(originalRecipient, updatedRecipient); + + // Handle recipient updated audit log. + if (changes.length > 0) { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, + documentId: documentId, + metadata: requestMetadata, + data: { + recipientEmail: updatedRecipient.email, + recipientName: updatedRecipient.name, + recipientId: updatedRecipient.id, + recipientRole: updatedRecipient.role, + changes, + }, + }), + }); + } + + return updatedRecipient; + }), + ); + }); + + return { + recipients: updatedRecipients, + }; +}; + +/** + * If you change this you MUST update the `hasRecipientBeenChanged` function. + */ +type RecipientData = { + id: number; + email?: string; + name?: string; + role?: RecipientRole; + signingOrder?: number | null; + accessAuth?: TRecipientAccessAuthTypes | null; + actionAuth?: TRecipientActionAuthTypes | null; +}; + +const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => { + const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions); + + return ( + recipient.email !== newRecipientData.email || + recipient.name !== newRecipientData.name || + recipient.role !== newRecipientData.role || + recipient.signingOrder !== newRecipientData.signingOrder || + authOptions.accessAuth !== newRecipientData.accessAuth || + authOptions.actionAuth !== newRecipientData.actionAuth + ); +}; diff --git a/packages/lib/server-only/recipient/update-template-recipients.ts b/packages/lib/server-only/recipient/update-template-recipients.ts new file mode 100644 index 000000000..290dcc970 --- /dev/null +++ b/packages/lib/server-only/recipient/update-template-recipients.ts @@ -0,0 +1,185 @@ +import { z } from 'zod'; + +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth'; +import { + type TRecipientActionAuthTypes, + ZRecipientAuthOptionsSchema, +} from '@documenso/lib/types/document-auth'; +import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth'; +import { prisma } from '@documenso/prisma'; +import { RecipientRole } from '@documenso/prisma/client'; +import { SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { ZRecipientResponseSchema } from '@documenso/trpc/server/recipient-router/schema'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export interface UpdateTemplateRecipientsOptions { + userId: number; + teamId?: number; + templateId: number; + recipients: { + id: number; + email?: string; + name?: string; + role?: RecipientRole; + signingOrder?: number | null; + accessAuth?: TRecipientAccessAuthTypes | null; + actionAuth?: TRecipientActionAuthTypes | null; + }[]; +} + +export const ZUpdateTemplateRecipientsResponseSchema = z.object({ + recipients: ZRecipientResponseSchema.array(), +}); + +export type TUpdateTemplateRecipientsResponse = z.infer< + typeof ZUpdateTemplateRecipientsResponseSchema +>; + +export const updateTemplateRecipients = async ({ + userId, + teamId, + templateId, + recipients, +}: UpdateTemplateRecipientsOptions): Promise => { + const template = await prisma.template.findFirst({ + where: { + id: templateId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, + include: { + Recipient: true, + }, + }); + + if (!template) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Template not found', + }); + } + + const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + + // Check if user has permission to set the global action auth. + if (recipientsHaveActionAuth) { + const isEnterprise = await isUserEnterprise({ + userId, + teamId, + }); + + if (!isEnterprise) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to set the action auth', + }); + } + } + + const recipientsToUpdate = recipients.map((recipient) => { + const originalRecipient = template.Recipient.find( + (existingRecipient) => existingRecipient.id === recipient.id, + ); + + if (!originalRecipient) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: `Recipient with id ${recipient.id} not found`, + }); + } + + const duplicateRecipientWithSameEmail = template.Recipient.find( + (existingRecipient) => + existingRecipient.email === recipient.email && existingRecipient.id !== recipient.id, + ); + + if (duplicateRecipientWithSameEmail) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `Duplicate recipient with the same email found: ${duplicateRecipientWithSameEmail.email}`, + }); + } + + return { + originalRecipient, + recipientUpdateData: recipient, + }; + }); + + const updatedRecipients = await prisma.$transaction(async (tx) => { + return await Promise.all( + recipientsToUpdate.map(async ({ originalRecipient, recipientUpdateData }) => { + let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions); + + if ( + recipientUpdateData.actionAuth !== undefined || + recipientUpdateData.accessAuth !== undefined + ) { + authOptions = createRecipientAuthOptions({ + accessAuth: recipientUpdateData.accessAuth || authOptions.accessAuth, + actionAuth: recipientUpdateData.actionAuth || authOptions.actionAuth, + }); + } + + const mergedRecipient = { + ...originalRecipient, + ...recipientUpdateData, + }; + + const updatedRecipient = await tx.recipient.update({ + where: { + id: originalRecipient.id, + templateId, + }, + data: { + name: mergedRecipient.name, + email: mergedRecipient.email, + role: mergedRecipient.role, + signingOrder: mergedRecipient.signingOrder, + templateId, + sendStatus: + mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT, + signingStatus: + mergedRecipient.role === RecipientRole.CC + ? SigningStatus.SIGNED + : SigningStatus.NOT_SIGNED, + authOptions, + }, + include: { + Field: true, + }, + }); + + // Clear all fields if the recipient role is changed to a type that cannot have fields. + if ( + originalRecipient.role !== updatedRecipient.role && + (updatedRecipient.role === RecipientRole.CC || + updatedRecipient.role === RecipientRole.VIEWER) + ) { + await tx.field.deleteMany({ + where: { + recipientId: updatedRecipient.id, + }, + }); + } + + return updatedRecipient; + }), + ); + }); + + return { + recipients: updatedRecipients, + }; +}; 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 68b0d8060..a37e1c865 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 @@ -33,7 +33,7 @@ import type { TRecipientActionAuthTypes } from '../../types/document-auth'; import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import { ZFieldMetaSchema } from '../../types/field-meta'; import { ZWebhookDocumentSchema } from '../../types/webhook-payload'; -import type { RequestMetadata } from '../../universal/extract-request-metadata'; +import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { @@ -55,7 +55,7 @@ export type CreateDocumentFromDirectTemplateOptions = { directTemplateExternalId?: string; signedFieldValues: TSignFieldWithTokenMutationSchema[]; templateUpdatedAt: Date; - requestMetadata: RequestMetadata; + requestMetadata: ApiRequestMetadata; user?: { id: number; name?: string; @@ -454,7 +454,7 @@ export const createDocumentFromDirectTemplate = async ({ name: user?.name, email: directRecipientEmail, }, - requestMetadata, + metadata: requestMetadata, data: { title: document.title, source: { @@ -472,7 +472,7 @@ export const createDocumentFromDirectTemplate = async ({ name: user?.name, email: directRecipientEmail, }, - requestMetadata, + metadata: requestMetadata, data: { recipientEmail: createdDirectRecipient.email, recipientId: createdDirectRecipient.id, @@ -490,7 +490,7 @@ export const createDocumentFromDirectTemplate = async ({ name: user?.name, email: directRecipientEmail, }, - requestMetadata, + metadata: requestMetadata, data: { recipientEmail: createdDirectRecipient.email, recipientId: createdDirectRecipient.id, @@ -535,7 +535,7 @@ export const createDocumentFromDirectTemplate = async ({ name: user?.name, email: directRecipientEmail, }, - requestMetadata, + metadata: requestMetadata, data: { recipientEmail: createdDirectRecipient.email, recipientId: createdDirectRecipient.id, diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index be497955d..e3aa84b30 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -26,7 +26,7 @@ import { ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import type { TDocumentEmailSettings } from '../../types/document-email'; import { ZFieldMetaSchema } from '../../types/field-meta'; import { ZWebhookDocumentSchema } from '../../types/webhook-payload'; -import type { RequestMetadata } from '../../universal/extract-request-metadata'; +import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuthOptions, @@ -73,7 +73,7 @@ export type CreateDocumentFromTemplateOptions = { typedSignatureEnabled?: boolean; emailSettings?: TDocumentEmailSettings; }; - requestMetadata?: RequestMetadata; + requestMetadata: ApiRequestMetadata; }; export const ZCreateDocumentFromTemplateResponseSchema = DocumentSchema.extend({ @@ -95,12 +95,6 @@ export const createDocumentFromTemplate = async ({ override, requestMetadata, }: CreateDocumentFromTemplateOptions): Promise => { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - }); - const template = await prisma.template.findUnique({ where: { id: templateId, @@ -312,8 +306,7 @@ export const createDocumentFromTemplate = async ({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, documentId: document.id, - user, - requestMetadata, + metadata: requestMetadata, data: { title: document.title, source: { diff --git a/packages/lib/server-only/template/create-template-direct-link.ts b/packages/lib/server-only/template/create-template-direct-link.ts index 388739498..0cdf1dd30 100644 --- a/packages/lib/server-only/template/create-template-direct-link.ts +++ b/packages/lib/server-only/template/create-template-direct-link.ts @@ -16,6 +16,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error'; export type CreateTemplateDirectLinkOptions = { templateId: number; userId: number; + teamId?: number; directRecipientId?: number; }; @@ -28,25 +29,27 @@ export type TCreateTemplateDirectLinkResponse = z.infer< export const createTemplateDirectLink = async ({ templateId, userId, + teamId, directRecipientId, }: CreateTemplateDirectLinkOptions): Promise => { const template = await prisma.template.findFirst({ where: { id: templateId, - OR: [ - { - userId, - }, - { - team: { - members: { - some: { - userId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, }, }, - }, - }, - ], + } + : { + userId, + teamId: null, + }), }, include: { Recipient: true, diff --git a/packages/lib/server-only/template/delete-template-direct-link.ts b/packages/lib/server-only/template/delete-template-direct-link.ts index 7f3d00606..3ba763d04 100644 --- a/packages/lib/server-only/template/delete-template-direct-link.ts +++ b/packages/lib/server-only/template/delete-template-direct-link.ts @@ -8,29 +8,32 @@ import { AppError, AppErrorCode } from '../../errors/app-error'; export type DeleteTemplateDirectLinkOptions = { templateId: number; userId: number; + teamId?: number; }; export const deleteTemplateDirectLink = async ({ templateId, userId, + teamId, }: DeleteTemplateDirectLinkOptions): Promise => { const template = await prisma.template.findFirst({ where: { id: templateId, - OR: [ - { - userId, - }, - { - team: { - members: { - some: { - userId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, }, }, - }, - }, - ], + } + : { + userId, + teamId: null, + }), }, include: { directLink: true, diff --git a/packages/lib/server-only/template/delete-template.ts b/packages/lib/server-only/template/delete-template.ts index 0962b6b9a..964f432b8 100644 --- a/packages/lib/server-only/template/delete-template.ts +++ b/packages/lib/server-only/template/delete-template.ts @@ -12,26 +12,21 @@ export const deleteTemplate = async ({ id, userId, teamId }: DeleteTemplateOptio return await prisma.template.delete({ where: { id, - OR: - teamId === undefined - ? [ - { - userId, - teamId: null, - }, - ] - : [ - { - teamId, - team: { - members: { - some: { - userId, - }, - }, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, }, }, - ], + }, + } + : { + userId, + teamId: null, + }), }, }); }; diff --git a/packages/lib/server-only/template/duplicate-template.ts b/packages/lib/server-only/template/duplicate-template.ts index 8d5722a82..8da685f6e 100644 --- a/packages/lib/server-only/template/duplicate-template.ts +++ b/packages/lib/server-only/template/duplicate-template.ts @@ -9,6 +9,7 @@ import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/te export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & { userId: number; + teamId?: number; }; export const ZDuplicateTemplateResponseSchema = TemplateSchema; @@ -20,28 +21,25 @@ export const duplicateTemplate = async ({ userId, teamId, }: DuplicateTemplateOptions): Promise => { - let templateWhereFilter: Prisma.TemplateWhereUniqueInput = { - id: templateId, - userId, - teamId: null, - }; - - if (teamId !== undefined) { - templateWhereFilter = { - id: templateId, - teamId, - team: { - members: { - some: { - userId, - }, - }, - }, - }; - } - const template = await prisma.template.findUnique({ - where: templateWhereFilter, + where: { + id: templateId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + } + : { + userId, + teamId: null, + }), + }, include: { Recipient: true, Field: true, diff --git a/packages/lib/server-only/template/get-template-by-id.ts b/packages/lib/server-only/template/get-template-by-id.ts index 1e4b36d0f..f287f410a 100644 --- a/packages/lib/server-only/template/get-template-by-id.ts +++ b/packages/lib/server-only/template/get-template-by-id.ts @@ -1,7 +1,6 @@ import type { z } from 'zod'; import { prisma } from '@documenso/prisma'; -import type { Prisma } from '@documenso/prisma/client'; import { DocumentDataSchema, FieldSchema, @@ -40,32 +39,25 @@ export const getTemplateById = async ({ userId, teamId, }: GetTemplateByIdOptions): Promise => { - const whereFilter: Prisma.TemplateWhereInput = { - id, - OR: - teamId === undefined - ? [ - { - userId, - teamId: null, - }, - ] - : [ - { - teamId, - team: { - members: { - some: { - userId, - }, + const template = await prisma.template.findFirst({ + where: { + id, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, }, }, }, - ], - }; - - const template = await prisma.template.findFirst({ - where: whereFilter, + } + : { + userId, + teamId: null, + }), + }, include: { directLink: true, templateDocumentData: true, diff --git a/packages/lib/server-only/template/toggle-template-direct-link.ts b/packages/lib/server-only/template/toggle-template-direct-link.ts index 7c1573ef9..09e81fda7 100644 --- a/packages/lib/server-only/template/toggle-template-direct-link.ts +++ b/packages/lib/server-only/template/toggle-template-direct-link.ts @@ -10,6 +10,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error'; export type ToggleTemplateDirectLinkOptions = { templateId: number; userId: number; + teamId?: number; enabled: boolean; }; @@ -22,25 +23,27 @@ export type TToggleTemplateDirectLinkResponse = z.infer< export const toggleTemplateDirectLink = async ({ templateId, userId, + teamId, enabled, }: ToggleTemplateDirectLinkOptions): Promise => { const template = await prisma.template.findFirst({ where: { id: templateId, - OR: [ - { - userId, - }, - { - team: { - members: { - some: { - userId, + ...(teamId + ? { + team: { + id: teamId, + members: { + some: { + userId, + }, }, }, - }, - }, - ], + } + : { + userId, + teamId: null, + }), }, include: { Recipient: true, diff --git a/packages/lib/server-only/template/update-template-settings.ts b/packages/lib/server-only/template/update-template.ts similarity index 77% rename from packages/lib/server-only/template/update-template-settings.ts rename to packages/lib/server-only/template/update-template.ts index 3c2bfb7c1..f425dc786 100644 --- a/packages/lib/server-only/template/update-template-settings.ts +++ b/packages/lib/server-only/template/update-template.ts @@ -3,7 +3,6 @@ import type { z } from 'zod'; import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; -import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; import type { DocumentVisibility, Template, TemplateMeta } from '@documenso/prisma/client'; import { TemplateSchema } from '@documenso/prisma/generated/zod'; @@ -12,11 +11,11 @@ import { AppError, AppErrorCode } from '../../errors/app-error'; import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth'; -export type UpdateTemplateSettingsOptions = { +export type UpdateTemplateOptions = { userId: number; teamId?: number; templateId: number; - data: { + data?: { title?: string; externalId?: string | null; visibility?: DocumentVisibility; @@ -27,26 +26,19 @@ export type UpdateTemplateSettingsOptions = { type?: Template['type']; }; meta?: Partial>; - requestMetadata?: RequestMetadata; }; -export const ZUpdateTemplateSettingsResponseSchema = TemplateSchema; +export const ZUpdateTemplateResponseSchema = TemplateSchema; -export type TUpdateTemplateSettingsResponse = z.infer; +export type TUpdateTemplateResponse = z.infer; -export const updateTemplateSettings = async ({ +export const updateTemplate = async ({ userId, teamId, templateId, - meta, - data, -}: UpdateTemplateSettingsOptions): Promise => { - if (Object.values(data).length === 0 && Object.keys(meta ?? {}).length === 0) { - throw new AppError(AppErrorCode.INVALID_BODY, { - message: 'Missing data to update', - }); - } - + meta = {}, + data = {}, +}: UpdateTemplateOptions): Promise => { const template = await prisma.template.findFirstOrThrow({ where: { id: templateId, @@ -71,6 +63,10 @@ export const updateTemplateSettings = async ({ }, }); + if (Object.values(data).length === 0 && Object.keys(meta).length === 0) { + return template; + } + const { documentAuthOption } = extractDocumentAuthMethods({ documentAuth: template.authOptions, }); @@ -108,12 +104,12 @@ export const updateTemplateSettings = async ({ id: templateId, }, data: { - title: data.title, - externalId: data.externalId, - type: data.type, - visibility: data.visibility, - publicDescription: data.publicDescription, - publicTitle: data.publicTitle, + title: data?.title, + externalId: data?.externalId, + type: data?.type, + visibility: data?.visibility, + publicDescription: data?.publicDescription, + publicTitle: data?.publicTitle, authOptions, templateMeta: { upsert: { diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index 689ca4a78..73073f7a8 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; import { DocumentSource, FieldType } from '@documenso/prisma/client'; -import { ZRecipientActionAuthTypesSchema } from './document-auth'; +import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from './document-auth'; export const ZDocumentAuditLogTypeSchema = z.enum([ // Document actions. @@ -127,11 +127,11 @@ export const ZGenericFromToSchema = z.object({ }); export const ZRecipientDiffActionAuthSchema = ZGenericFromToSchema.extend({ - type: z.literal(RECIPIENT_DIFF_TYPE.ACCESS_AUTH), + type: z.literal(RECIPIENT_DIFF_TYPE.ACTION_AUTH), }); export const ZRecipientDiffAccessAuthSchema = ZGenericFromToSchema.extend({ - type: z.literal(RECIPIENT_DIFF_TYPE.ACTION_AUTH), + type: z.literal(RECIPIENT_DIFF_TYPE.ACCESS_AUTH), }); export const ZRecipientDiffNameSchema = ZGenericFromToSchema.extend({ @@ -438,6 +438,7 @@ export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({ export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED), data: ZBaseRecipientDataSchema.extend({ + accessAuth: ZRecipientAccessAuthTypesSchema.optional(), actionAuth: ZRecipientActionAuthTypesSchema.optional(), }), }); diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts index eccd119eb..f0979754d 100644 --- a/packages/lib/types/document-auth.ts +++ b/packages/lib/types/document-auth.ts @@ -48,7 +48,9 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ * Must keep these two in sync. */ export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); -export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); +export const ZDocumentAccessAuthTypesSchema = z + .enum([DocumentAuth.ACCOUNT]) + .describe('The type of authentication required for the recipient to access the document.'); /** * The global document action auth methods. @@ -60,11 +62,11 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ ZDocumentAuthPasskeySchema, ZDocumentAuth2FASchema, ]); -export const ZDocumentActionAuthTypesSchema = z.enum([ - DocumentAuth.ACCOUNT, - DocumentAuth.PASSKEY, - DocumentAuth.TWO_FACTOR_AUTH, -]); +export const ZDocumentActionAuthTypesSchema = z + .enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH]) + .describe( + 'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.', + ); /** * The recipient access auth methods. @@ -74,7 +76,9 @@ export const ZDocumentActionAuthTypesSchema = z.enum([ export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [ ZDocumentAuthAccountSchema, ]); -export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]); +export const ZRecipientAccessAuthTypesSchema = z + .enum([DocumentAuth.ACCOUNT]) + .describe('The type of authentication required for the recipient to access the document.'); /** * The recipient action auth methods. @@ -87,12 +91,14 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [ ZDocumentAuth2FASchema, ZDocumentAuthExplicitNoneSchema, ]); -export const ZRecipientActionAuthTypesSchema = z.enum([ - DocumentAuth.ACCOUNT, - DocumentAuth.PASSKEY, - DocumentAuth.TWO_FACTOR_AUTH, - DocumentAuth.EXPLICIT_NONE, -]); +export const ZRecipientActionAuthTypesSchema = z + .enum([ + DocumentAuth.ACCOUNT, + DocumentAuth.PASSKEY, + DocumentAuth.TWO_FACTOR_AUTH, + DocumentAuth.EXPLICIT_NONE, + ]) + .describe('The type of authentication required for the recipient to sign the document.'); export const DocumentAccessAuth = ZDocumentAccessAuthTypesSchema.Enum; export const DocumentActionAuth = ZDocumentActionAuthTypesSchema.Enum; diff --git a/packages/lib/types/document-email.ts b/packages/lib/types/document-email.ts index 7a0e07305..ccf23d38e 100644 --- a/packages/lib/types/document-email.ts +++ b/packages/lib/types/document-email.ts @@ -15,13 +15,44 @@ export enum DocumentEmailEvents { export const ZDocumentEmailSettingsSchema = z .object({ - recipientSigningRequest: z.boolean().default(true), - recipientRemoved: z.boolean().default(true), - recipientSigned: z.boolean().default(true), - documentPending: z.boolean().default(true), - documentCompleted: z.boolean().default(true), - documentDeleted: z.boolean().default(true), - ownerDocumentCompleted: z.boolean().default(true), + recipientSigningRequest: z + .boolean() + .describe( + 'Whether to send an email to all recipients that the document is ready for them to sign.', + ) + .default(true), + recipientRemoved: z + .boolean() + .describe( + 'Whether to send an email to the recipient who was removed from a pending document.', + ) + .default(true), + recipientSigned: z + .boolean() + .describe( + 'Whether to send an email to the document owner when a recipient has signed the document.', + ) + .default(true), + documentPending: z + .boolean() + .describe( + 'Whether to send an email to the recipient who has just signed the document indicating that there are still other recipients who need to sign the document. This will only be sent if the document is still pending after the recipient has signed.', + ) + .default(true), + documentCompleted: z + .boolean() + .describe('Whether to send an email to all recipients when the document is complete.') + .default(true), + documentDeleted: z + .boolean() + .describe( + 'Whether to send an email to all recipients if a pending document has been deleted.', + ) + .default(true), + ownerDocumentCompleted: z + .boolean() + .describe('Whether to send an email to the document owner when the document is complete.') + .default(true), }) .strip() .catch(() => ({ diff --git a/packages/lib/types/search-params.ts b/packages/lib/types/search-params.ts index 9501c96ed..35a166b95 100644 --- a/packages/lib/types/search-params.ts +++ b/packages/lib/types/search-params.ts @@ -8,9 +8,9 @@ import { z } from 'zod'; * Keep this and `ZUrlSearchParamsSchema` in sync. */ export const ZFindSearchParamsSchema = z.object({ - query: z.string().optional(), - page: z.coerce.number().min(1).optional(), - perPage: z.coerce.number().min(1).optional(), + query: z.string().describe('The search query.').optional(), + page: z.coerce.number().min(1).describe('The pagination page number, starts at 1.').optional(), + perPage: z.coerce.number().min(1).describe('The number of items per page.').max(100).optional(), }); /** @@ -31,16 +31,17 @@ export const ZUrlSearchParamsSchema = z.object({ perPage: z.coerce .number() .min(1) + .max(100) .optional() .catch(() => undefined), }); export const ZFindResultResponse = z.object({ - data: z.union([z.array(z.unknown()), z.unknown()]), - count: z.number(), - currentPage: z.number(), - perPage: z.number(), - totalPages: z.number(), + data: z.union([z.array(z.unknown()), z.unknown()]).describe('The results from the search.'), + count: z.number().describe('The total number of items.'), + currentPage: z.number().describe('The current page number, starts at 1.'), + perPage: z.number().describe('The number of items per page.'), + totalPages: z.number().describe('The total number of pages.'), }); // Can't infer generics from Zod. diff --git a/packages/lib/universal/extract-request-metadata.ts b/packages/lib/universal/extract-request-metadata.ts index 3129a060b..d66322eee 100644 --- a/packages/lib/universal/extract-request-metadata.ts +++ b/packages/lib/universal/extract-request-metadata.ts @@ -12,6 +12,36 @@ export const ZRequestMetadataSchema = z.object({ export type RequestMetadata = z.infer; +export type ApiRequestMetadata = { + /** + * The general metadata of the request. + */ + requestMetadata: RequestMetadata; + + /** + * The source of the request. + */ + source: 'apiV1' | 'apiV2' | 'app'; + + /** + * The method of authentication used to access the API. + * + * If the request is not authenticated, the value will be `null`. + */ + auth: 'api' | 'session' | null; + + /** + * The user that is performing the action. + * + * If a team API key is used, the user will classified as the team. + */ + auditUser?: { + id: number | null; + email: string | null; + name: string | null; + }; +}; + export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetadata => { const parsedIp = ZIpSchema.safeParse(req.headers['x-forwarded-for'] || req.socket.remoteAddress); diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index bb4f9bc8e..339bf453b 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -19,14 +19,15 @@ import { ZDocumentAuditLogSchema, } from '../types/document-audit-logs'; import { ZRecipientAuthOptionsSchema } from '../types/document-auth'; -import type { RequestMetadata } from '../universal/extract-request-metadata'; +import type { ApiRequestMetadata, RequestMetadata } from '../universal/extract-request-metadata'; type CreateDocumentAuditLogDataOptions = { documentId: number; type: T; data: Extract['data']; - user: { email?: string; id?: number | null; name?: string | null } | null; + user?: { email?: string | null; id?: number | null; name?: string | null } | null; requestMetadata?: RequestMetadata; + metadata?: ApiRequestMetadata; }; export type CreateDocumentAuditLogDataResponse = Pick< @@ -42,16 +43,31 @@ export const createDocumentAuditLogData = ( data, user, requestMetadata, + metadata, }: CreateDocumentAuditLogDataOptions): CreateDocumentAuditLogDataResponse => { + let userId: number | null = metadata?.auditUser?.id || null; + let email: string | null = metadata?.auditUser?.email || null; + let name: string | null = metadata?.auditUser?.name || null; + + // Prioritize explicit user parameter over metadata audit user. + if (user) { + userId = user.id || null; + email = user.email || null; + name = user.name || null; + } + + const ipAddress = metadata?.requestMetadata.ipAddress ?? requestMetadata?.ipAddress ?? null; + const userAgent = metadata?.requestMetadata.userAgent ?? requestMetadata?.userAgent ?? null; + return { type, data, documentId, - userId: user?.id ?? null, - email: user?.email ?? null, - name: user?.name ?? null, - userAgent: requestMetadata?.userAgent ?? null, - ipAddress: requestMetadata?.ipAddress ?? null, + userId, + email, + name, + userAgent, + ipAddress, }; }; diff --git a/packages/trpc/react/index.tsx b/packages/trpc/react/index.tsx index 0a6028b08..846fe9e1a 100644 --- a/packages/trpc/react/index.tsx +++ b/packages/trpc/react/index.tsx @@ -35,9 +35,10 @@ export const trpc = createTRPCReact({ export interface TrpcProviderProps { children: React.ReactNode; + headers?: Record; } -export function TrpcProvider({ children }: TrpcProviderProps) { +export function TrpcProvider({ children, headers }: TrpcProviderProps) { let queryClientConfig: QueryClientConfig | undefined; const isDevelopingOffline = @@ -63,15 +64,16 @@ export function TrpcProvider({ children }: TrpcProviderProps) { const [trpcClient] = useState(() => trpc.createClient({ transformer: SuperJSON, - links: [ splitLink({ condition: (op) => op.context.skipBatch === true, true: httpLink({ url: `${getBaseUrl()}/api/trpc`, + headers, }), false: httpBatchLink({ url: `${getBaseUrl()}/api/trpc`, + headers, }), }), ], diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index 2ee4b7d8b..72971da45 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -17,7 +17,6 @@ import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys'; import { compareSync } from '@documenso/lib/server-only/auth/hash'; import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey'; import { createUser } from '@documenso/lib/server-only/user/create-user'; -import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -89,7 +88,7 @@ export const authRouter = router({ userId: ctx.user.id, verificationResponse, passkeyName: input.passkeyName, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata.requestMetadata, }); }), @@ -132,7 +131,7 @@ export const authRouter = router({ await deletePasskey({ userId: ctx.user.id, passkeyId, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata.requestMetadata, }); }), @@ -158,7 +157,7 @@ export const authRouter = router({ userId: ctx.user.id, passkeyId, name, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata.requestMetadata, }); }), }); diff --git a/packages/trpc/server/context.ts b/packages/trpc/server/context.ts index 7136afd70..b9dbd9cb0 100644 --- a/packages/trpc/server/context.ts +++ b/packages/trpc/server/context.ts @@ -1,15 +1,37 @@ -import type { CreateNextContextOptions } from '@trpc/server/adapters/next'; +import { z } from 'zod'; import { getServerSession } from '@documenso/lib/next-auth/get-server-session'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; -export const createTrpcContext = async ({ req, res }: CreateNextContextOptions) => { +import type { CreateNextContextOptions } from './adapters/next'; + +type CreateTrpcContext = CreateNextContextOptions & { + requestSource: 'apiV1' | 'apiV2' | 'app'; +}; + +export const createTrpcContext = async ({ req, res, requestSource }: CreateTrpcContext) => { const { session, user } = await getServerSession({ req, res }); + const metadata: ApiRequestMetadata = { + requestMetadata: extractNextApiRequestMetadata(req), + source: requestSource, + auth: null, + }; + + const teamId = z.coerce + .number() + .optional() + .catch(() => undefined) + .parse(req.headers['x-team-id']); + if (!session) { return { session: null, user: null, + teamId, req, + metadata, }; } @@ -17,14 +39,18 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions) return { session: null, user: null, + teamId, req, + metadata, }; } return { session, user, + teamId, req, + metadata, }; }; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index b47a66e4e..3e254bb32 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -39,12 +39,10 @@ import { sendDocument, } from '@documenso/lib/server-only/document/send-document'; import { - ZUpdateDocumentSettingsResponseSchema, - updateDocumentSettings, -} from '@documenso/lib/server-only/document/update-document-settings'; -import { updateTitle } from '@documenso/lib/server-only/document/update-title'; + ZUpdateDocumentResponseSchema, + updateDocument, +} from '@documenso/lib/server-only/document/update-document'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; -import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { DocumentStatus } from '@documenso/prisma/client'; import { authenticatedProcedure, procedure, router } from '../trpc'; @@ -64,9 +62,8 @@ import { ZSearchDocumentsMutationSchema, ZSendDocumentMutationSchema, ZSetPasswordForDocumentMutationSchema, - ZSetSettingsForDocumentMutationSchema, ZSetSigningOrderForDocumentMutationSchema, - ZSetTitleForDocumentMutationSchema, + ZUpdateDocumentRequestSchema, ZUpdateTypedSignatureSettingsMutationSchema, } from './schema'; @@ -77,9 +74,13 @@ export const documentRouter = router({ getDocumentById: authenticatedProcedure .input(ZGetDocumentByIdQuerySchema) .query(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId } = input; + return await getDocumentById({ - ...input, userId: ctx.user.id, + teamId, + documentId, }); }), @@ -104,28 +105,19 @@ export const documentRouter = router({ .meta({ openapi: { method: 'GET', - path: '/document/find', + path: '/document', summary: 'Find documents', description: 'Find documents based on a search criteria', - tags: ['Documents'], + tags: ['Document'], }, }) .input(ZFindDocumentsQuerySchema) .output(ZFindDocumentsResponseSchema) .query(async ({ input, ctx }) => { - const { user } = ctx; + const { user, teamId } = ctx; - const { - query, - teamId, - templateId, - page, - perPage, - orderByDirection, - orderByColumn, - source, - status, - } = input; + const { query, templateId, page, perPage, orderByDirection, orderByColumn, source, status } = + input; const documents = await findDocuments({ userId: user.id, @@ -154,34 +146,41 @@ export const documentRouter = router({ path: '/document/{documentId}', summary: 'Get document', description: 'Returns a document given an ID', - tags: ['Documents'], + tags: ['Document'], }, }) .input(ZGetDocumentWithDetailsByIdQuerySchema) .output(ZGetDocumentWithDetailsByIdResponseSchema) .query(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { documentId } = input; + return await getDocumentWithDetailsById({ - ...input, - userId: ctx.user.id, + userId: user.id, + teamId, + documentId, }); }), /** - * @public + * Wait until RR7 so we can passthrough documents. + * + * @private */ createDocument: authenticatedProcedure - .meta({ - openapi: { - method: 'POST', - path: '/document/create', - summary: 'Create document', - tags: ['Documents'], - }, - }) + // .meta({ + // openapi: { + // method: 'POST', + // path: '/document/create', + // summary: 'Create document', + // tags: ['Document'], + // }, + // }) .input(ZCreateDocumentMutationSchema) .output(ZCreateDocumentResponseSchema) .mutation(async ({ input, ctx }) => { - const { title, documentDataId, teamId, timezone } = input; + const { teamId } = ctx; + const { title, documentDataId, timezone } = input; const { remaining } = await getServerLimits({ email: ctx.user.email, teamId }); @@ -199,7 +198,7 @@ export const documentRouter = router({ documentDataId, normalizePdf: true, timezone, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata, }); }), @@ -212,38 +211,43 @@ export const documentRouter = router({ .meta({ openapi: { method: 'POST', - path: '/document/{documentId}', + path: '/document/update', summary: 'Update document', - tags: ['Documents'], + tags: ['Document'], }, }) - .input(ZSetSettingsForDocumentMutationSchema) - .output(ZUpdateDocumentSettingsResponseSchema) + .input(ZUpdateDocumentRequestSchema) + .output(ZUpdateDocumentResponseSchema) .mutation(async ({ input, ctx }) => { - const { documentId, teamId, data, meta } = input; + const { teamId } = ctx; + const { documentId, data, meta = {} } = input; const userId = ctx.user.id; - const requestMetadata = extractNextApiRequestMetadata(ctx.req); - - if (meta.timezone || meta.dateFormat || meta.redirectUrl) { + if (Object.values(meta).length > 0) { await upsertDocumentMeta({ - documentId, - dateFormat: meta.dateFormat, - timezone: meta.timezone, - redirectUrl: meta.redirectUrl, - language: meta.language, userId: ctx.user.id, - requestMetadata, + teamId, + documentId, + subject: meta.subject, + message: meta.message, + timezone: meta.timezone, + dateFormat: meta.dateFormat, + language: meta.language, + typedSignatureEnabled: meta.typedSignatureEnabled, + redirectUrl: meta.redirectUrl, + distributionMethod: meta.distributionMethod, + emailSettings: meta.emailSettings, + requestMetadata: ctx.metadata, }); } - return await updateDocumentSettings({ + return await updateDocument({ userId, teamId, documentId, data, - requestMetadata, + requestMetadata: ctx.metadata, }); }), @@ -253,16 +257,17 @@ export const documentRouter = router({ deleteDocument: authenticatedProcedure .meta({ openapi: { - method: 'POST', - path: '/document/{documentId}/delete', + method: 'DELETE', + path: '/document/{documentId}', summary: 'Delete document', - tags: ['Documents'], + tags: ['Document'], }, }) .input(ZDeleteDocumentMutationSchema) .output(z.void()) .mutation(async ({ input, ctx }) => { - const { documentId, teamId } = input; + const { teamId } = ctx; + const { documentId } = input; const userId = ctx.user.id; @@ -270,7 +275,7 @@ export const documentRouter = router({ id: documentId, userId, teamId, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata, }); }), @@ -281,10 +286,10 @@ export const documentRouter = router({ .meta({ openapi: { method: 'POST', - path: '/document/{documentId}/move', + path: '/document/move', summary: 'Move document', - description: 'Move a document to a team', - tags: ['Documents'], + description: 'Move a document from your personal account to a team', + tags: ['Document'], }, }) .input(ZMoveDocumentToTeamSchema) @@ -297,27 +302,7 @@ export const documentRouter = router({ documentId, teamId, userId, - requestMetadata: extractNextApiRequestMetadata(ctx.req), - }); - }), - - /** - * @private - */ - // Should probably use `updateDocument` - setTitleForDocument: authenticatedProcedure - .input(ZSetTitleForDocumentMutationSchema) - .mutation(async ({ input, ctx }) => { - const { documentId, teamId, title } = input; - - const userId = ctx.user.id; - - return await updateTitle({ - title, - userId, - teamId, - documentId, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata, }); }), @@ -327,6 +312,7 @@ export const documentRouter = router({ setPasswordForDocument: authenticatedProcedure .input(ZSetPasswordForDocumentMutationSchema) .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; const { documentId, password } = input; const key = DOCUMENSO_ENCRYPTION_KEY; @@ -341,10 +327,11 @@ export const documentRouter = router({ }); await upsertDocumentMeta({ + userId: ctx.user.id, + teamId, documentId, password: securePassword, - userId: ctx.user.id, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata, }); }), @@ -354,23 +341,28 @@ export const documentRouter = router({ setSigningOrderForDocument: authenticatedProcedure .input(ZSetSigningOrderForDocumentMutationSchema) .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; const { documentId, signingOrder } = input; return await upsertDocumentMeta({ + userId: ctx.user.id, + teamId, documentId, signingOrder, - userId: ctx.user.id, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata, }); }), /** + * @deprecated Remove after deployment. + * * @private */ updateTypedSignatureSettings: authenticatedProcedure .input(ZUpdateTypedSignatureSettingsMutationSchema) .mutation(async ({ input, ctx }) => { - const { documentId, teamId, typedSignatureEnabled } = input; + const { teamId } = ctx; + const { documentId, typedSignatureEnabled } = input; const document = await getDocumentById({ documentId, @@ -386,10 +378,11 @@ export const documentRouter = router({ } return await upsertDocumentMeta({ + userId: ctx.user.id, + teamId, documentId, typedSignatureEnabled, - userId: ctx.user.id, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata, }); }), @@ -403,27 +396,22 @@ export const documentRouter = router({ .meta({ openapi: { method: 'POST', - path: '/document/{documentId}/distribute', + path: '/document/distribute', summary: 'Distribute document', description: 'Send the document out to recipients based on your distribution method', - tags: ['Documents'], + tags: ['Document'], }, }) .input(ZSendDocumentMutationSchema) .output(ZSendDocumentResponseSchema) .mutation(async ({ input, ctx }) => { - const { documentId, teamId, meta } = input; + const { teamId } = ctx; + const { documentId, meta = {} } = input; - if ( - meta.message || - meta.subject || - meta.timezone || - meta.dateFormat || - meta.redirectUrl || - meta.distributionMethod || - meta.emailSettings - ) { + if (Object.values(meta).length > 0) { await upsertDocumentMeta({ + userId: ctx.user.id, + teamId, documentId, subject: meta.subject, message: meta.message, @@ -431,9 +419,9 @@ export const documentRouter = router({ timezone: meta.timezone, redirectUrl: meta.redirectUrl, distributionMethod: meta.distributionMethod, - userId: ctx.user.id, emailSettings: meta.emailSettings, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + language: meta.language, + requestMetadata: ctx.metadata, }); } @@ -441,31 +429,38 @@ export const documentRouter = router({ userId: ctx.user.id, documentId, teamId, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata, }); }), /** * @public + * + * Todo: Refactor to redistributeDocument. */ resendDocument: authenticatedProcedure .meta({ openapi: { method: 'POST', - path: '/document/{documentId}/resend', - summary: 'Resend document', + path: '/document/redistribute', + summary: 'Redistribute document', description: - 'Resend the document to recipients who have not signed. Will use the distribution method set in the document.', - tags: ['Documents'], + '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(z.void()) .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId, recipients } = input; + return await resendDocument({ userId: ctx.user.id, - ...input, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + teamId, + documentId, + recipients, + requestMetadata: ctx.metadata, }); }), @@ -476,17 +471,21 @@ export const documentRouter = router({ .meta({ openapi: { method: 'POST', - path: '/document/{documentId}/duplicate', + path: '/document/duplicate', summary: 'Duplicate document', - tags: ['Documents'], + tags: ['Document'], }, }) .input(ZDuplicateDocumentMutationSchema) .output(ZDuplicateDocumentResponseSchema) .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { documentId } = input; + return await duplicateDocument({ - userId: ctx.user.id, - ...input, + userId: user.id, + teamId, + documentId, }); }), @@ -512,6 +511,8 @@ export const documentRouter = router({ findDocumentAuditLogs: authenticatedProcedure .input(ZFindDocumentAuditLogsQuerySchema) .query(async ({ input, ctx }) => { + const { teamId } = ctx; + const { page, perPage, @@ -523,13 +524,14 @@ export const documentRouter = router({ } = input; return await findDocumentAuditLogs({ + userId: ctx.user.id, + teamId, page, perPage, documentId, cursor, filterForRecentActivity, orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, - userId: ctx.user.id, }); }), @@ -539,7 +541,8 @@ export const documentRouter = router({ downloadAuditLogs: authenticatedProcedure .input(ZDownloadAuditLogsMutationSchema) .mutation(async ({ input, ctx }) => { - const { documentId, teamId } = input; + const { teamId } = ctx; + const { documentId } = input; const document = await getDocumentById({ documentId, @@ -570,7 +573,8 @@ export const documentRouter = router({ downloadCertificate: authenticatedProcedure .input(ZDownloadCertificateMutationSchema) .mutation(async ({ input, ctx }) => { - const { documentId, teamId } = input; + const { teamId } = ctx; + const { documentId } = input; const document = await getDocumentById({ documentId, diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index f51488b3c..565399a45 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -15,16 +15,59 @@ import { DocumentStatus, DocumentVisibility, FieldType, - RecipientRole, } from '@documenso/prisma/client'; +// Todo: Refactor all to ZDocumentMeta--- +export const ZDocumentMetaTimezoneSchema = z + .string() + .describe('The timezone to use for date fields and signing the document.'); + +export const ZDocumentMetaDateFormatSchema = z + .string() + .describe('The date format to use for date fields and signing the document.'); + +export const ZDocumentMetaRedirectUrlSchema = z + .string() + .describe('The URL to which the recipient should be redirected after signing the document.') + .refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), { + message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.', + }); + +export const ZDocumentMetaLanguageSchema = z + .enum(SUPPORTED_LANGUAGE_CODES) + .describe('The language to use for email communications with recipients.'); + +export const ZDocumentMetaSubjectSchema = z + .string() + .describe('The subject of the email that will be sent to the recipients.'); + +export const ZDocumentMetaMessageSchema = z + .string() + .describe('The message of the email that will be sent to the recipients.'); + +export const ZDocumentMetaDistributionMethodSchema = z + .nativeEnum(DocumentDistributionMethod) + .describe('The distribution method to use when sending the document to the recipients.'); + +export const ZDocumentMetaTypedSignatureEnabledSchema = z + .boolean() + .describe('Whether to allow typed signatures.'); + export const ZFindDocumentsQuerySchema = ZFindSearchParamsSchema.extend({ - teamId: z.number().min(1).optional(), - templateId: z.number().min(1).optional(), - source: z.nativeEnum(DocumentSource).optional(), - status: z.nativeEnum(DocumentStatus).optional(), + 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(), orderByColumn: z.enum(['createdAt']).optional(), - orderByDirection: z.enum(['asc', 'desc']).default('desc'), + orderByDirection: z.enum(['asc', 'desc']).describe('').default('desc'), }); export const ZFindDocumentAuditLogsQuerySchema = ZFindSearchParamsSchema.extend({ @@ -36,13 +79,11 @@ export const ZFindDocumentAuditLogsQuerySchema = ZFindSearchParamsSchema.extend( }); export const ZGetDocumentByIdQuerySchema = z.object({ - documentId: z.number().min(1), - teamId: z.number().min(1).optional(), + documentId: z.number(), }); export const ZDuplicateDocumentMutationSchema = z.object({ - documentId: z.number().min(1), - teamId: z.number().min(1).optional(), + documentId: z.number(), }); export type TGetDocumentByIdQuerySchema = z.infer; @@ -54,8 +95,7 @@ export const ZGetDocumentByTokenQuerySchema = z.object({ export type TGetDocumentByTokenQuerySchema = z.infer; export const ZGetDocumentWithDetailsByIdQuerySchema = z.object({ - documentId: z.number().min(1), - teamId: z.number().min(1).optional(), + documentId: z.number(), }); export type TGetDocumentWithDetailsByIdQuerySchema = z.infer< @@ -65,64 +105,41 @@ export type TGetDocumentWithDetailsByIdQuerySchema = z.infer< export const ZCreateDocumentMutationSchema = z.object({ title: z.string().min(1), documentDataId: z.string().min(1), - teamId: z.number().optional(), timezone: z.string().optional(), }); export type TCreateDocumentMutationSchema = z.infer; -export const ZSetSettingsForDocumentMutationSchema = z.object({ +export const ZUpdateDocumentRequestSchema = z.object({ documentId: z.number(), - teamId: z.number().min(1).optional(), - data: z.object({ - title: z.string().min(1).optional(), - externalId: z.string().nullish(), - visibility: z.nativeEnum(DocumentVisibility).optional(), - globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(), - }), - meta: z.object({ - timezone: z.string(), - dateFormat: z.string(), - redirectUrl: z - .string() - .optional() - .refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), { - message: - 'Please enter a valid URL, make sure you include http:// or https:// part of the url.', - }), - language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(), - }), + data: z + .object({ + title: z.string().describe('The title of the document.').min(1).optional(), + externalId: z.string().nullish().describe('The external ID of the document.'), + visibility: z + .nativeEnum(DocumentVisibility) + .describe('The visibility of the document.') + .optional(), + globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(), + globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(), + }) + .optional(), + meta: z + .object({ + subject: ZDocumentMetaSubjectSchema.optional(), + message: ZDocumentMetaMessageSchema.optional(), + timezone: ZDocumentMetaTimezoneSchema.optional(), + dateFormat: ZDocumentMetaDateFormatSchema.optional(), + distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(), + redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), + language: ZDocumentMetaLanguageSchema.optional(), + typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), + emailSettings: ZDocumentEmailSettingsSchema.optional(), + }) + .optional(), }); -export type TSetGeneralSettingsForDocumentMutationSchema = z.infer< - typeof ZSetSettingsForDocumentMutationSchema ->; - -export const ZSetTitleForDocumentMutationSchema = z.object({ - documentId: z.number(), - teamId: z.number().min(1).optional(), - title: z.string().min(1), -}); - -export type TSetTitleForDocumentMutationSchema = z.infer; - -export const ZSetRecipientsForDocumentMutationSchema = z.object({ - documentId: z.number(), - teamId: z.number().min(1).optional(), - recipients: z.array( - z.object({ - id: z.number().nullish(), - email: z.string().min(1).email(), - name: z.string(), - role: z.nativeEnum(RecipientRole), - }), - ), -}); - -export type TSetRecipientsForDocumentMutationSchema = z.infer< - typeof ZSetRecipientsForDocumentMutationSchema ->; +export type TUpdateDocumentRequestSchema = z.infer; export const ZSetFieldsForDocumentMutationSchema = z.object({ documentId: z.number(), @@ -145,23 +162,19 @@ export type TSetFieldsForDocumentMutationSchema = z.infer< >; export const ZSendDocumentMutationSchema = z.object({ - documentId: z.number(), - teamId: z.number().optional(), - meta: z.object({ - subject: z.string(), - message: z.string(), - timezone: z.string().optional(), - dateFormat: z.string().optional(), - distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(), - redirectUrl: z - .string() - .optional() - .refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), { - message: - 'Please enter a valid URL, make sure you include http:// or https:// part of the url.', - }), - emailSettings: ZDocumentEmailSettingsSchema.optional(), - }), + 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(), + emailSettings: ZDocumentEmailSettingsSchema.optional(), + }) + .optional(), }); export const ZSetPasswordForDocumentMutationSchema = z.object({ @@ -184,7 +197,6 @@ export type TSetSigningOrderForDocumentMutationSchema = z.infer< export const ZUpdateTypedSignatureSettingsMutationSchema = z.object({ documentId: z.number(), - teamId: z.number().optional(), typedSignatureEnabled: z.boolean(), }); @@ -194,15 +206,16 @@ export type TUpdateTypedSignatureSettingsMutationSchema = z.infer< export const ZResendDocumentMutationSchema = z.object({ documentId: z.number(), - recipients: z.array(z.number()).min(1), - teamId: z.number().min(1).optional(), + recipients: z + .array(z.number()) + .min(1) + .describe('The IDs of the recipients to redistribute the document to.'), }); export type TSendDocumentMutationSchema = z.infer; export const ZDeleteDocumentMutationSchema = z.object({ - documentId: z.number().min(1), - teamId: z.number().min(1).optional(), + documentId: z.number(), }); export type TDeleteDocumentMutationSchema = z.infer; @@ -213,15 +226,13 @@ export const ZSearchDocumentsMutationSchema = z.object({ export const ZDownloadAuditLogsMutationSchema = z.object({ documentId: z.number(), - teamId: z.number().optional(), }); export const ZDownloadCertificateMutationSchema = z.object({ documentId: z.number(), - teamId: z.number().optional(), }); export const ZMoveDocumentToTeamSchema = z.object({ - documentId: z.number(), - teamId: z.number(), + documentId: z.number().describe('The ID of the document to move to a team.'), + teamId: z.number().describe('The ID of the team to move the document to.'), }); diff --git a/packages/trpc/server/field-router/router.ts b/packages/trpc/server/field-router/router.ts index b42bb3a6b..31bfc92ab 100644 --- a/packages/trpc/server/field-router/router.ts +++ b/packages/trpc/server/field-router/router.ts @@ -1,3 +1,15 @@ +import { z } from 'zod'; + +import { + ZCreateDocumentFieldsResponseSchema, + createDocumentFields, +} from '@documenso/lib/server-only/field/create-document-fields'; +import { + ZCreateTemplateFieldsResponseSchema, + createTemplateFields, +} from '@documenso/lib/server-only/field/create-template-fields'; +import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field'; +import { deleteTemplateField } from '@documenso/lib/server-only/field/delete-template-field'; import { ZGetFieldByIdResponseSchema, getFieldById, @@ -12,15 +24,37 @@ import { setFieldsForTemplate, } from '@documenso/lib/server-only/field/set-fields-for-template'; import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token'; +import { + ZUpdateDocumentFieldsResponseSchema, + updateDocumentFields, +} from '@documenso/lib/server-only/field/update-document-fields'; +import { + ZUpdateTemplateFieldsResponseSchema, + updateTemplateFields, +} from '@documenso/lib/server-only/field/update-template-fields'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { ZAddFieldsMutationSchema, ZAddTemplateFieldsMutationSchema, + ZCreateDocumentFieldRequestSchema, + ZCreateDocumentFieldResponseSchema, + ZCreateDocumentFieldsRequestSchema, + ZCreateTemplateFieldRequestSchema, + ZCreateTemplateFieldResponseSchema, + ZCreateTemplateFieldsRequestSchema, + ZDeleteDocumentFieldRequestSchema, + ZDeleteTemplateFieldRequestSchema, ZGetFieldQuerySchema, ZRemovedSignedFieldWithTokenMutationSchema, ZSignFieldWithTokenMutationSchema, + ZUpdateDocumentFieldRequestSchema, + ZUpdateDocumentFieldResponseSchema, + ZUpdateDocumentFieldsRequestSchema, + ZUpdateTemplateFieldRequestSchema, + ZUpdateTemplateFieldResponseSchema, + ZUpdateTemplateFieldsRequestSchema, } from './schema'; export const fieldRouter = router({ @@ -35,13 +69,14 @@ export const fieldRouter = router({ summary: 'Get field', description: 'Returns a single field. If you want to retrieve all the fields for a document or template, use the "Get Document" or "Get Template" request.', - tags: ['Fields'], + tags: ['Document Fields', 'Template Fields'], }, }) .input(ZGetFieldQuerySchema) .output(ZGetFieldByIdResponseSchema) .query(async ({ input, ctx }) => { - const { fieldId, teamId } = input; + const { teamId } = ctx; + const { fieldId } = input; return await getFieldById({ userId: ctx.user.id, @@ -53,23 +88,167 @@ export const fieldRouter = router({ /** * @public */ - addFields: authenticatedProcedure + createDocumentField: authenticatedProcedure .meta({ openapi: { method: 'POST', - path: '/document/{documentId}/field', - summary: 'Set document fields', - tags: ['Fields'], + path: '/document/field/create', + summary: 'Create document field', + description: 'Create a single field for a document.', + tags: ['Document Fields'], }, }) + .input(ZCreateDocumentFieldRequestSchema) + .output(ZCreateDocumentFieldResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId, field } = input; + + const createdFields = await createDocumentFields({ + userId: ctx.user.id, + teamId, + documentId, + fields: [field], + requestMetadata: ctx.metadata, + }); + + return createdFields.fields[0]; + }), + + /** + * @public + */ + createDocumentFields: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/document/field/create-many', + summary: 'Create document fields', + description: 'Create multiple fields for a document.', + tags: ['Document Fields'], + }, + }) + .input(ZCreateDocumentFieldsRequestSchema) + .output(ZCreateDocumentFieldsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId, fields } = input; + + return await createDocumentFields({ + userId: ctx.user.id, + teamId, + documentId, + fields, + requestMetadata: ctx.metadata, + }); + }), + + /** + * @public + */ + updateDocumentField: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/document/field/update', + summary: 'Update document field', + description: 'Update a single field for a document.', + tags: ['Document Fields'], + }, + }) + .input(ZUpdateDocumentFieldRequestSchema) + .output(ZUpdateDocumentFieldResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId, field } = input; + + const updatedFields = await updateDocumentFields({ + userId: ctx.user.id, + teamId, + documentId, + fields: [field], + requestMetadata: ctx.metadata, + }); + + return updatedFields.fields[0]; + }), + + /** + * @public + */ + updateDocumentFields: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/document/field/update-many', + summary: 'Update document fields', + description: 'Update multiple fields for a document.', + tags: ['Document Fields'], + }, + }) + .input(ZUpdateDocumentFieldsRequestSchema) + .output(ZUpdateDocumentFieldsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId, fields } = input; + + return await updateDocumentFields({ + userId: ctx.user.id, + teamId, + documentId, + fields, + requestMetadata: ctx.metadata, + }); + }), + + /** + * @public + */ + deleteDocumentField: authenticatedProcedure + .meta({ + openapi: { + method: 'DELETE', + path: '/document/field/{fieldId}', + summary: 'Delete document field', + tags: ['Document Fields'], + }, + }) + .input(ZDeleteDocumentFieldRequestSchema) + .output(z.void()) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { fieldId } = input; + + await deleteDocumentField({ + userId: ctx.user.id, + teamId, + fieldId, + requestMetadata: ctx.metadata, + }); + }), + + /** + * @private + */ + addFields: authenticatedProcedure + // .meta({ + // openapi: { + // method: 'POST', + // path: '/document/{documentId}/field', + // summary: 'Set document fields', + // tags: ['Document Fields'], + // }, + // }) .input(ZAddFieldsMutationSchema) .output(ZSetFieldsForDocumentResponseSchema) .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; const { documentId, fields } = input; return await setFieldsForDocument({ documentId, userId: ctx.user.id, + teamId, fields: fields.map((field) => ({ id: field.nativeId, signerEmail: field.signerEmail, @@ -81,30 +260,169 @@ export const fieldRouter = router({ pageHeight: field.pageHeight, fieldMeta: field.fieldMeta, })), - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata, }); }), /** * @public */ - addTemplateFields: authenticatedProcedure + createTemplateField: authenticatedProcedure .meta({ openapi: { method: 'POST', - path: '/template/{templateId}/field', - summary: 'Set template fields', - tags: ['Fields'], + path: '/template/field/create', + summary: 'Create template field', + description: 'Create a single field for a template.', + tags: ['Template Fields'], }, }) + .input(ZCreateTemplateFieldRequestSchema) + .output(ZCreateTemplateFieldResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { templateId, field } = input; + + const createdFields = await createTemplateFields({ + userId: ctx.user.id, + teamId, + templateId, + fields: [field], + }); + + return createdFields.fields[0]; + }), + + /** + * @public + */ + createTemplateFields: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/template/field/create-many', + summary: 'Create template fields', + description: 'Create multiple fields for a template.', + tags: ['Template Fields'], + }, + }) + .input(ZCreateTemplateFieldsRequestSchema) + .output(ZCreateTemplateFieldsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { templateId, fields } = input; + + return await createTemplateFields({ + userId: ctx.user.id, + teamId, + templateId, + fields, + }); + }), + + /** + * @public + */ + updateTemplateField: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/template/field/update', + summary: 'Update template field', + description: 'Update a single field for a template.', + tags: ['Template Fields'], + }, + }) + .input(ZUpdateTemplateFieldRequestSchema) + .output(ZUpdateTemplateFieldResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { templateId, field } = input; + + const updatedFields = await updateTemplateFields({ + userId: ctx.user.id, + teamId, + templateId, + fields: [field], + }); + + return updatedFields.fields[0]; + }), + + /** + * @public + */ + updateTemplateFields: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/template/field/update-many', + summary: 'Update template fields', + description: 'Update multiple fields for a template.', + tags: ['Template Fields'], + }, + }) + .input(ZUpdateTemplateFieldsRequestSchema) + .output(ZUpdateTemplateFieldsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { templateId, fields } = input; + + return await updateTemplateFields({ + userId: ctx.user.id, + teamId, + templateId, + fields, + }); + }), + + /** + * @public + */ + deleteTemplateField: authenticatedProcedure + .meta({ + openapi: { + method: 'DELETE', + path: '/template/field/{fieldId}', + summary: 'Delete template field', + tags: ['Template Fields'], + }, + }) + .input(ZDeleteTemplateFieldRequestSchema) + .output(z.void()) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { fieldId } = input; + + await deleteTemplateField({ + userId: ctx.user.id, + teamId, + fieldId, + }); + }), + + /** + * @private + */ + addTemplateFields: authenticatedProcedure + // .meta({ + // openapi: { + // method: 'POST', + // path: '/template/{templateId}/field', + // summary: 'Set template fields', + // tags: ['Template Fields'], + // }, + // }) .input(ZAddTemplateFieldsMutationSchema) .output(ZSetFieldsForTemplateResponseSchema) .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; const { templateId, fields } = input; return await setFieldsForTemplate({ - userId: ctx.user.id, templateId, + userId: ctx.user.id, + teamId, fields: fields.map((field) => ({ id: field.nativeId, signerEmail: field.signerEmail, diff --git a/packages/trpc/server/field-router/schema.ts b/packages/trpc/server/field-router/schema.ts index 4f408fc2c..feaa20f95 100644 --- a/packages/trpc/server/field-router/schema.ts +++ b/packages/trpc/server/field-router/schema.ts @@ -3,6 +3,82 @@ import { z } from 'zod'; import { ZRecipientActionAuthSchema } from '@documenso/lib/types/document-auth'; import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { FieldType } from '@documenso/prisma/client'; +import { FieldSchema } from '@documenso/prisma/generated/zod'; + +const ZCreateFieldSchema = z.object({ + recipientId: z.number().describe('The ID of the recipient to create the field for.'), + type: FieldSchema.shape.type.describe('The type of the field to create.'), + pageNumber: z.number().describe('The page number the field will be on.'), + pageX: z.number().describe('The X coordinate of where the field will be placed.'), + pageY: z.number().describe('The Y coordinate of where the field will be placed.'), + width: z.number().describe('The width of the field.'), + height: z.number().describe('The height of the field.'), + fieldMeta: ZFieldMetaSchema.optional(), +}); + +const ZUpdateFieldSchema = z.object({ + id: z.number().describe('The ID of the field to update.'), + type: FieldSchema.shape.type.optional().describe('The type of the field to update.'), + pageNumber: z.number().optional().describe('The page number the field will be on.'), + pageX: z.number().optional().describe('The X coordinate of where the field will be placed.'), + pageY: z.number().optional().describe('The Y coordinate of where the field will be placed.'), + width: z.number().optional().describe('The width of the field.'), + height: z.number().optional().describe('The height of the field.'), + fieldMeta: ZFieldMetaSchema.optional(), +}); + +export const ZCreateDocumentFieldRequestSchema = z.object({ + documentId: z.number().min(1), + field: ZCreateFieldSchema, +}); + +export const ZCreateDocumentFieldsRequestSchema = z.object({ + documentId: z.number().min(1), + fields: ZCreateFieldSchema.array(), +}); + +export const ZUpdateDocumentFieldRequestSchema = z.object({ + documentId: z.number().min(1), + field: ZUpdateFieldSchema, +}); + +export const ZUpdateDocumentFieldsRequestSchema = z.object({ + documentId: z.number().min(1), + fields: ZUpdateFieldSchema.array(), +}); + +export const ZDeleteDocumentFieldRequestSchema = z.object({ + fieldId: z.number().min(1), +}); + +export const ZCreateTemplateFieldRequestSchema = z.object({ + templateId: z.number().min(1), + field: ZCreateFieldSchema, +}); + +export const ZCreateDocumentFieldResponseSchema = FieldSchema; +export const ZUpdateTemplateFieldResponseSchema = FieldSchema; +export const ZUpdateDocumentFieldResponseSchema = FieldSchema; +export const ZCreateTemplateFieldResponseSchema = FieldSchema; + +export const ZCreateTemplateFieldsRequestSchema = z.object({ + templateId: z.number().min(1), + fields: ZCreateFieldSchema.array(), +}); + +export const ZUpdateTemplateFieldRequestSchema = z.object({ + templateId: z.number().min(1), + field: ZUpdateFieldSchema, +}); + +export const ZUpdateTemplateFieldsRequestSchema = z.object({ + templateId: z.number().min(1), + fields: ZUpdateFieldSchema.array(), +}); + +export const ZDeleteTemplateFieldRequestSchema = z.object({ + fieldId: z.number().min(1), +}); export const ZAddFieldsMutationSchema = z.object({ documentId: z.number(), @@ -65,7 +141,6 @@ export type TRemovedSignedFieldWithTokenMutationSchema = z.infer< export const ZGetFieldQuerySchema = z.object({ fieldId: z.number(), - teamId: z.number().optional(), }); export type TGetFieldQuerySchema = z.infer; diff --git a/packages/trpc/server/open-api.ts b/packages/trpc/server/open-api.ts index 05414cf7b..d1964b33c 100644 --- a/packages/trpc/server/open-api.ts +++ b/packages/trpc/server/open-api.ts @@ -5,8 +5,8 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { appRouter } from './router'; export const openApiDocument = generateOpenApiDocument(appRouter, { - title: 'Do not use.', + title: 'Documenso v2 beta API', + description: 'Subject to breaking changes until v2 is fully released.', version: '0.0.0', - baseUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/beta`, - // docsUrl: '', // Todo + baseUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta`, }); diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 2ee779e65..e87a726c8 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -52,7 +52,7 @@ export const profileRouter = router({ userId: ctx.user.id, name, signature, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata.requestMetadata, }); }), @@ -96,7 +96,7 @@ export const profileRouter = router({ userId: ctx.user.id, password, currentPassword, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata.requestMetadata, }); }), @@ -146,7 +146,7 @@ export const profileRouter = router({ userId: ctx.user.id, teamId, bytes, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata, }); }), }); diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 3fa0d63c3..757061bca 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -1,26 +1,57 @@ +import { z } from 'zod'; + import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token'; +import { + ZCreateDocumentRecipientsResponseSchema, + createDocumentRecipients, +} from '@documenso/lib/server-only/recipient/create-document-recipients'; +import { + ZCreateTemplateRecipientsResponseSchema, + createTemplateRecipients, +} from '@documenso/lib/server-only/recipient/create-template-recipients'; +import { deleteDocumentRecipient } from '@documenso/lib/server-only/recipient/delete-document-recipient'; +import { deleteTemplateRecipient } from '@documenso/lib/server-only/recipient/delete-template-recipient'; import { ZGetRecipientByIdResponseSchema, getRecipientById, } from '@documenso/lib/server-only/recipient/get-recipient-by-id'; import { - ZSetRecipientsForDocumentResponseSchema, - setRecipientsForDocument, -} from '@documenso/lib/server-only/recipient/set-recipients-for-document'; + ZSetDocumentRecipientsResponseSchema, + setDocumentRecipients, +} from '@documenso/lib/server-only/recipient/set-document-recipients'; import { - ZSetRecipientsForTemplateResponseSchema, - setRecipientsForTemplate, -} from '@documenso/lib/server-only/recipient/set-recipients-for-template'; + ZSetTemplateRecipientsResponseSchema, + setTemplateRecipients, +} from '@documenso/lib/server-only/recipient/set-template-recipients'; +import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients'; +import { updateTemplateRecipients } from '@documenso/lib/server-only/recipient/update-template-recipients'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { ZAddSignersMutationSchema, - ZAddTemplateSignersMutationSchema, ZCompleteDocumentWithTokenMutationSchema, + ZCreateDocumentRecipientRequestSchema, + ZCreateDocumentRecipientResponseSchema, + ZCreateDocumentRecipientsRequestSchema, + ZCreateTemplateRecipientRequestSchema, + ZCreateTemplateRecipientResponseSchema, + ZCreateTemplateRecipientsRequestSchema, + ZDeleteDocumentRecipientRequestSchema, + ZDeleteTemplateRecipientRequestSchema, ZGetRecipientQuerySchema, ZRejectDocumentWithTokenMutationSchema, + ZSetDocumentRecipientsRequestSchema, + ZSetTemplateRecipientsRequestSchema, + ZUpdateDocumentRecipientRequestSchema, + ZUpdateDocumentRecipientResponseSchema, + ZUpdateDocumentRecipientsRequestSchema, + ZUpdateDocumentRecipientsResponseSchema, + ZUpdateTemplateRecipientRequestSchema, + ZUpdateTemplateRecipientResponseSchema, + ZUpdateTemplateRecipientsRequestSchema, + ZUpdateTemplateRecipientsResponseSchema, } from './schema'; export const recipientRouter = router({ @@ -35,13 +66,14 @@ export const recipientRouter = router({ summary: 'Get recipient', description: 'Returns a single recipient. If you want to retrieve all the recipients for a document or template, use the "Get Document" or "Get Template" request.', - tags: ['Recipients'], + tags: ['Document Recipients', 'Template Recipients'], }, }) .input(ZGetRecipientQuerySchema) .output(ZGetRecipientByIdResponseSchema) .query(async ({ input, ctx }) => { - const { recipientId, teamId } = input; + const { teamId } = ctx; + const { recipientId } = input; return await getRecipientById({ userId: ctx.user.id, @@ -53,64 +85,349 @@ export const recipientRouter = router({ /** * @public */ - addSigners: authenticatedProcedure + createDocumentRecipient: authenticatedProcedure .meta({ openapi: { method: 'POST', - path: '/document/{documentId}/recipient/set', - summary: 'Set document recipients', - tags: ['Recipients'], + path: '/document/recipient/create', + summary: 'Create document recipient', + description: 'Create a single recipient for a document.', + tags: ['Document Recipients'], }, }) - .input(ZAddSignersMutationSchema) - .output(ZSetRecipientsForDocumentResponseSchema) + .input(ZCreateDocumentRecipientRequestSchema) + .output(ZCreateDocumentRecipientResponseSchema) .mutation(async ({ input, ctx }) => { - const { documentId, teamId, signers } = input; + const { teamId } = ctx; + const { documentId, recipient } = input; - return await setRecipientsForDocument({ + const createdRecipients = await createDocumentRecipients({ userId: ctx.user.id, - documentId, teamId, - recipients: signers.map((signer) => ({ - id: signer.nativeId, - email: signer.email, - name: signer.name, - role: signer.role, - signingOrder: signer.signingOrder, - actionAuth: signer.actionAuth, - })), - requestMetadata: extractNextApiRequestMetadata(ctx.req), + documentId, + recipients: [recipient], + requestMetadata: ctx.metadata, + }); + + return createdRecipients.recipients[0]; + }), + + /** + * @public + */ + createDocumentRecipients: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/document/recipient/create-many', + summary: 'Create document recipients', + description: 'Create multiple recipients for a document.', + tags: ['Document Recipients'], + }, + }) + .input(ZCreateDocumentRecipientsRequestSchema) + .output(ZCreateDocumentRecipientsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId, recipients } = input; + + return await createDocumentRecipients({ + userId: ctx.user.id, + teamId, + documentId, + recipients, + requestMetadata: ctx.metadata, }); }), /** * @public */ - addTemplateSigners: authenticatedProcedure + updateDocumentRecipient: authenticatedProcedure .meta({ openapi: { method: 'POST', - path: '/template/{templateId}/recipient/set', - summary: 'Set template recipients', - tags: ['Recipients'], + path: '/document/recipient/update', + summary: 'Update document recipient', + description: 'Update a single recipient for a document.', + tags: ['Document Recipients'], }, }) - .input(ZAddTemplateSignersMutationSchema) - .output(ZSetRecipientsForTemplateResponseSchema) + .input(ZUpdateDocumentRecipientRequestSchema) + .output(ZUpdateDocumentRecipientResponseSchema) .mutation(async ({ input, ctx }) => { - const { templateId, signers, teamId } = input; + const { teamId } = ctx; + const { documentId, recipient } = input; - return await setRecipientsForTemplate({ + const updatedRecipients = await updateDocumentRecipients({ + userId: ctx.user.id, + teamId, + documentId, + recipients: [recipient], + requestMetadata: ctx.metadata, + }); + + return updatedRecipients.recipients[0]; + }), + + /** + * @public + */ + updateDocumentRecipients: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/document/recipient/update-many', + summary: 'Update document recipients', + description: 'Update multiple recipients for a document.', + tags: ['Document Recipients'], + }, + }) + .input(ZUpdateDocumentRecipientsRequestSchema) + .output(ZUpdateDocumentRecipientsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId, recipients } = input; + + return await updateDocumentRecipients({ + userId: ctx.user.id, + teamId, + documentId, + recipients, + requestMetadata: ctx.metadata, + }); + }), + + /** + * @public + */ + deleteDocumentRecipient: authenticatedProcedure + .meta({ + openapi: { + method: 'DELETE', + path: '/document/recipient/{recipientId}', + summary: 'Delete document recipient', + tags: ['Document Recipients'], + }, + }) + .input(ZDeleteDocumentRecipientRequestSchema) + .output(z.void()) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { recipientId } = input; + + await deleteDocumentRecipient({ + userId: ctx.user.id, + teamId, + recipientId, + requestMetadata: ctx.metadata, + }); + }), + + /** + * @private + */ + setDocumentRecipients: authenticatedProcedure + // .meta({ + // openapi: { + // method: 'POST', + // path: '/document/recipient/set', + // summary: 'Set document recipients', + // description: + // 'This will replace all recipients attached to the document. If the array contains existing recipients, they will be updated and the original fields will be retained.', + // tags: ['Document Recipients'], + // }, + // }) + .input(ZSetDocumentRecipientsRequestSchema) + .output(ZSetDocumentRecipientsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId, recipients } = input; + + return await setDocumentRecipients({ + userId: ctx.user.id, + teamId, + documentId, + recipients: recipients.map((recipient) => ({ + id: recipient.nativeId, + email: recipient.email, + name: recipient.name, + role: recipient.role, + signingOrder: recipient.signingOrder, + actionAuth: recipient.actionAuth, + })), + requestMetadata: ctx.metadata, + }); + }), + + /** + * @public + */ + createTemplateRecipient: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/template/recipient/create', + summary: 'Create template recipient', + description: 'Create a single recipient for a template.', + tags: ['Template Recipients'], + }, + }) + .input(ZCreateTemplateRecipientRequestSchema) + .output(ZCreateTemplateRecipientResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { templateId, recipient } = input; + + const createdRecipients = await createTemplateRecipients({ userId: ctx.user.id, teamId, templateId, - recipients: signers.map((signer) => ({ - id: signer.nativeId, - email: signer.email, - name: signer.name, - role: signer.role, - signingOrder: signer.signingOrder, - actionAuth: signer.actionAuth, + recipients: [recipient], + }); + + return createdRecipients.recipients[0]; + }), + + /** + * @public + */ + createTemplateRecipients: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/template/recipient/create-many', + summary: 'Create template recipients', + description: 'Create multiple recipients for a template.', + tags: ['Template Recipients'], + }, + }) + .input(ZCreateTemplateRecipientsRequestSchema) + .output(ZCreateTemplateRecipientsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { templateId, recipients } = input; + + return await createTemplateRecipients({ + userId: ctx.user.id, + teamId, + templateId, + recipients, + }); + }), + + /** + * @public + */ + updateTemplateRecipient: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/template/recipient/update', + summary: 'Update template recipient', + description: 'Update a single recipient for a template.', + tags: ['Template Recipients'], + }, + }) + .input(ZUpdateTemplateRecipientRequestSchema) + .output(ZUpdateTemplateRecipientResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { templateId, recipient } = input; + + const updatedRecipients = await updateTemplateRecipients({ + userId: ctx.user.id, + teamId, + templateId, + recipients: [recipient], + }); + + return updatedRecipients.recipients[0]; + }), + + /** + * @public + */ + updateTemplateRecipients: authenticatedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/template/recipient/update-many', + summary: 'Update template recipients', + description: 'Update multiple recipients for a template.', + tags: ['Template Recipients'], + }, + }) + .input(ZUpdateTemplateRecipientsRequestSchema) + .output(ZUpdateTemplateRecipientsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { templateId, recipients } = input; + + return await updateTemplateRecipients({ + userId: ctx.user.id, + teamId, + templateId, + recipients, + }); + }), + + /** + * @public + */ + deleteTemplateRecipient: authenticatedProcedure + .meta({ + openapi: { + method: 'DELETE', + path: '/template/recipient/{recipientId}', + summary: 'Delete template recipient', + tags: ['Template Recipients'], + }, + }) + .input(ZDeleteTemplateRecipientRequestSchema) + .output(z.void()) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { recipientId } = input; + + await deleteTemplateRecipient({ + recipientId, + userId: ctx.user.id, + teamId, + }); + }), + + /** + * @private + */ + setTemplateRecipients: authenticatedProcedure + // .meta({ + // openapi: { + // method: 'POST', + // path: '/template/recipient/set', + // summary: 'Set template recipients', + // description: + // 'This will replace all recipients attached to the template. If the array contains existing recipients, they will be updated and the original fields will be retained.', + // tags: ['Template Recipients'], + // }, + // }) + .input(ZSetTemplateRecipientsRequestSchema) + .output(ZSetTemplateRecipientsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { templateId, recipients } = input; + + return await setTemplateRecipients({ + userId: ctx.user.id, + teamId, + templateId, + recipients: recipients.map((recipient) => ({ + id: recipient.nativeId, + email: recipient.email, + name: recipient.name, + role: recipient.role, + signingOrder: recipient.signingOrder, + actionAuth: recipient.actionAuth, })), }); }), @@ -147,4 +464,32 @@ export const recipientRouter = router({ requestMetadata: extractNextApiRequestMetadata(ctx.req), }); }), + + /** + * Leaving this here and will remove after deployment. + * + * @deprecated Remove after deployment. + */ + addSigners: authenticatedProcedure + .input(ZAddSignersMutationSchema) + .output(ZSetDocumentRecipientsResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; + const { documentId, signers } = input; + + return await setDocumentRecipients({ + userId: ctx.user.id, + documentId, + teamId, + recipients: signers.map((signer) => ({ + id: signer.nativeId, + email: signer.email, + name: signer.name, + role: signer.role, + signingOrder: signer.signingOrder, + actionAuth: signer.actionAuth, + })), + requestMetadata: ctx.metadata, + }); + }), }); diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 9dea0b42c..0e047f176 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -1,24 +1,114 @@ import { z } from 'zod'; import { + ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthSchema, ZRecipientActionAuthTypesSchema, } from '@documenso/lib/types/document-auth'; import { RecipientRole } from '@documenso/prisma/client'; +import { FieldSchema, RecipientSchema } from '@documenso/prisma/generated/zod'; export const ZGetRecipientQuerySchema = z.object({ recipientId: z.number(), - teamId: z.number().optional(), }); -export const ZAddSignersMutationSchema = z +const ZCreateRecipientSchema = z.object({ + email: z.string().toLowerCase().email().min(1), + name: z.string(), + role: z.nativeEnum(RecipientRole), + signingOrder: z.number().optional(), + accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(), + actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), +}); + +const ZUpdateRecipientSchema = z.object({ + id: z.number().describe('The ID of the recipient to update.'), + email: z.string().toLowerCase().email().min(1).optional(), + name: z.string().optional(), + role: z.nativeEnum(RecipientRole).optional(), + signingOrder: z.number().optional(), + accessAuth: ZRecipientAccessAuthTypesSchema.optional().nullable(), + actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), +}); + +/** + * Use this when returning base recipients from the API. + */ +export const ZRecipientBaseResponseSchema = RecipientSchema.pick({ + id: true, + documentId: true, + templateId: true, + email: true, + name: true, + token: true, + documentDeletedAt: true, + expired: true, + signedAt: true, + authOptions: true, + signingOrder: true, + rejectionReason: true, + role: true, + readStatus: true, + signingStatus: true, + sendStatus: true, +}); + +/** + * Use this when returning a full recipient from the API. + */ +export const ZRecipientResponseSchema = ZRecipientBaseResponseSchema.extend({ + Field: FieldSchema.array(), +}); + +export const ZCreateDocumentRecipientRequestSchema = z.object({ + documentId: z.number(), + recipient: ZCreateRecipientSchema, +}); + +export const ZCreateDocumentRecipientResponseSchema = ZRecipientBaseResponseSchema; + +export const ZCreateDocumentRecipientsRequestSchema = z.object({ + documentId: z.number(), + recipients: z.array(ZCreateRecipientSchema).refine((recipients) => { + const emails = recipients.map((recipient) => recipient.email.toLowerCase()); + + return new Set(emails).size === emails.length; + }), +}); + +export const ZUpdateDocumentRecipientRequestSchema = z.object({ + documentId: z.number(), + recipient: ZUpdateRecipientSchema, +}); + +export const ZUpdateDocumentRecipientResponseSchema = ZRecipientResponseSchema; + +export const ZUpdateDocumentRecipientsRequestSchema = z.object({ + documentId: z.number(), + recipients: z.array(ZUpdateRecipientSchema).refine((recipients) => { + const emails = recipients + .filter((recipient) => recipient.email !== undefined) + .map((recipient) => recipient.email?.toLowerCase()); + + return new Set(emails).size === emails.length; + }), +}); + +export const ZUpdateDocumentRecipientsResponseSchema = z.object({ + recipients: z.array(ZRecipientResponseSchema), +}); + +export const ZDeleteDocumentRecipientRequestSchema = z.object({ + recipientId: z.number(), +}); + +export const ZSetDocumentRecipientsRequestSchema = z .object({ documentId: z.number(), - teamId: z.number().optional(), - signers: z.array( + recipients: z.array( z.object({ nativeId: z.number().optional(), - email: z.string().email().min(1), + email: z.string().toLowerCase().email().min(1), name: z.string(), role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(), @@ -28,7 +118,7 @@ export const ZAddSignersMutationSchema = z }) .refine( (schema) => { - const emails = schema.signers.map((signer) => signer.email.toLowerCase()); + const emails = schema.recipients.map((recipient) => recipient.email.toLowerCase()); return new Set(emails).size === emails.length; }, @@ -36,16 +126,59 @@ export const ZAddSignersMutationSchema = z { message: 'Signers must have unique emails', path: ['signers__root'] }, ); -export type TAddSignersMutationSchema = z.infer; +export const ZCreateTemplateRecipientRequestSchema = z.object({ + templateId: z.number(), + recipient: ZCreateRecipientSchema, +}); -export const ZAddTemplateSignersMutationSchema = z +export const ZCreateTemplateRecipientResponseSchema = ZRecipientBaseResponseSchema; + +export const ZCreateTemplateRecipientsRequestSchema = z.object({ + templateId: z.number(), + recipients: z.array(ZCreateRecipientSchema).refine((recipients) => { + const emails = recipients.map((recipient) => recipient.email); + + return new Set(emails).size === emails.length; + }), +}); + +export const ZUpdateTemplateRecipientRequestSchema = z.object({ + templateId: z.number(), + recipient: ZUpdateRecipientSchema, +}); + +export const ZUpdateTemplateRecipientResponseSchema = ZRecipientResponseSchema; + +export const ZUpdateTemplateRecipientsRequestSchema = z.object({ + templateId: z.number(), + recipients: z.array(ZUpdateRecipientSchema).refine((recipients) => { + const emails = recipients + .filter((recipient) => recipient.email !== undefined) + .map((recipient) => recipient.email); + + return new Set(emails).size === emails.length; + }), +}); + +export const ZUpdateTemplateRecipientsResponseSchema = z.object({ + recipients: z.array(ZRecipientResponseSchema).refine((recipients) => { + const emails = recipients.map((recipient) => recipient.email); + + return new Set(emails).size === emails.length; + }), +}); + +export const ZDeleteTemplateRecipientRequestSchema = z.object({ + recipientId: z.number(), +}); + +export const ZSetTemplateRecipientsRequestSchema = z .object({ - teamId: z.number().optional(), templateId: z.number(), - signers: z.array( + recipients: z.array( z.object({ nativeId: z.number().optional(), - email: z.string().email().min(1), + email: z.string().toLowerCase().email().min(1), name: z.string(), role: z.nativeEnum(RecipientRole), signingOrder: z.number().optional(), @@ -55,16 +188,14 @@ export const ZAddTemplateSignersMutationSchema = z }) .refine( (schema) => { - const emails = schema.signers.map((signer) => signer.email.toLowerCase()); + const emails = schema.recipients.map((recipient) => recipient.email); return new Set(emails).size === emails.length; }, // Dirty hack to handle errors when .root is populated for an array type - { message: 'Signers must have unique emails', path: ['signers__root'] }, + { message: 'Recipients must have unique emails', path: ['recipients__root'] }, ); -export type TAddTemplateSignersMutationSchema = z.infer; - export const ZCompleteDocumentWithTokenMutationSchema = z.object({ token: z.string(), documentId: z.number(), @@ -85,3 +216,32 @@ export const ZRejectDocumentWithTokenMutationSchema = z.object({ export type TRejectDocumentWithTokenMutationSchema = z.infer< typeof ZRejectDocumentWithTokenMutationSchema >; + +/** + * Legacy schema. Remove after deployment (when addSigners trpc is removed). + * + * @deprecated + */ +export const ZAddSignersMutationSchema = z + .object({ + documentId: z.number(), + signers: z.array( + z.object({ + nativeId: z.number().optional(), + email: z.string().toLowerCase().email().min(1), + name: z.string(), + role: z.nativeEnum(RecipientRole), + signingOrder: z.number().optional(), + actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(), + }), + ), + }) + .refine( + (schema) => { + const emails = schema.signers.map((signer) => signer.email.toLowerCase()); + + return new Set(emails).size === emails.length; + }, + // Dirty hack to handle errors when .root is populated for an array type + { message: 'Signers must have unique emails', path: ['signers__root'] }, + ); diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index 7ea651364..b50a1adaf 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -1,8 +1,6 @@ -import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; -import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { ZGetDocumentWithDetailsByIdResponseSchema, @@ -45,10 +43,9 @@ import { toggleTemplateDirectLink, } from '@documenso/lib/server-only/template/toggle-template-direct-link'; import { - ZUpdateTemplateSettingsResponseSchema, - updateTemplateSettings, -} from '@documenso/lib/server-only/template/update-template-settings'; -import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; + ZUpdateTemplateResponseSchema, + updateTemplate, +} from '@documenso/lib/server-only/template/update-template'; import type { Document } from '@documenso/prisma/client'; import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc'; @@ -63,10 +60,8 @@ import { ZFindTemplatesQuerySchema, ZGetTemplateByIdQuerySchema, ZMoveTemplatesToTeamSchema, - ZSetSigningOrderForTemplateMutationSchema, ZToggleTemplateDirectLinkMutationSchema, - ZUpdateTemplateSettingsMutationSchema, - ZUpdateTemplateTypedSignatureSettingsMutationSchema, + ZUpdateTemplateRequestSchema, } from './schema'; export const templateRouter = router({ @@ -77,7 +72,7 @@ export const templateRouter = router({ .meta({ openapi: { method: 'GET', - path: '/template/find', + path: '/template', summary: 'Find templates', description: 'Find templates based on a search criteria', tags: ['Template'], @@ -86,8 +81,11 @@ export const templateRouter = router({ .input(ZFindTemplatesQuerySchema) .output(ZFindTemplatesResponseSchema) .query(async ({ input, ctx }) => { + const { teamId } = ctx; + return await findTemplates({ userId: ctx.user.id, + teamId, ...input, }); }), @@ -107,7 +105,8 @@ export const templateRouter = router({ .input(ZGetTemplateByIdQuerySchema) .output(ZGetTemplateByIdResponseSchema) .query(async ({ input, ctx }) => { - const { templateId, teamId } = input; + const { teamId } = ctx; + const { templateId } = input; return await getTemplateById({ id: templateId, @@ -117,22 +116,25 @@ export const templateRouter = router({ }), /** - * @public + * Wait until RR7 so we can passthrough documents. + * + * @private */ createTemplate: authenticatedProcedure - .meta({ - openapi: { - method: 'POST', - path: '/template/create', - summary: 'Create template', - description: 'Create a new template', - tags: ['Template'], - }, - }) + // .meta({ + // openapi: { + // method: 'POST', + // path: '/template/create', + // summary: 'Create template', + // description: 'Create a new template', + // tags: ['Template'], + // }, + // }) .input(ZCreateTemplateMutationSchema) .output(ZCreateTemplateResponseSchema) .mutation(async ({ input, ctx }) => { - const { teamId, title, templateDocumentDataId } = input; + const { teamId } = ctx; + const { title, templateDocumentDataId } = input; return await createTemplate({ userId: ctx.user.id, @@ -149,30 +151,25 @@ export const templateRouter = router({ .meta({ openapi: { method: 'POST', - path: '/template/{templateId}', + path: '/template/update', summary: 'Update template', tags: ['Template'], }, }) - .input(ZUpdateTemplateSettingsMutationSchema) - .output(ZUpdateTemplateSettingsResponseSchema) + .input(ZUpdateTemplateRequestSchema) + .output(ZUpdateTemplateResponseSchema) .mutation(async ({ input, ctx }) => { - const { templateId, teamId, data, meta } = input; + const { teamId } = ctx; + const { templateId, data, meta } = input; const userId = ctx.user.id; - const requestMetadata = extractNextApiRequestMetadata(ctx.req); - - return await updateTemplateSettings({ + return await updateTemplate({ userId, teamId, templateId, data, - meta: { - ...meta, - language: isValidLanguageCode(meta?.language) ? meta?.language : undefined, - }, - requestMetadata, + meta, }); }), @@ -183,7 +180,7 @@ export const templateRouter = router({ .meta({ openapi: { method: 'POST', - path: '/template/{templateId}/duplicate', + path: '/template/duplicate', summary: 'Duplicate template', tags: ['Template'], }, @@ -191,7 +188,8 @@ export const templateRouter = router({ .input(ZDuplicateTemplateMutationSchema) .output(ZDuplicateTemplateResponseSchema) .mutation(async ({ input, ctx }) => { - const { teamId, templateId } = input; + const { teamId } = ctx; + const { templateId } = input; return await duplicateTemplate({ userId: ctx.user.id, @@ -206,8 +204,8 @@ export const templateRouter = router({ deleteTemplate: authenticatedProcedure .meta({ openapi: { - method: 'POST', - path: '/template/{templateId}/delete', + method: 'DELETE', + path: '/template/{templateId}', summary: 'Delete template', tags: ['Template'], }, @@ -215,7 +213,8 @@ export const templateRouter = router({ .input(ZDeleteTemplateMutationSchema) .output(z.void()) .mutation(async ({ input, ctx }) => { - const { templateId, teamId } = input; + const { teamId } = ctx; + const { templateId } = input; const userId = ctx.user.id; @@ -229,7 +228,7 @@ export const templateRouter = router({ .meta({ openapi: { method: 'POST', - path: '/template/{templateId}/use', + path: '/template/use', summary: 'Use template', description: 'Use the template to create a document', tags: ['Template'], @@ -238,7 +237,8 @@ export const templateRouter = router({ .input(ZCreateDocumentFromTemplateMutationSchema) .output(ZGetDocumentWithDetailsByIdResponseSchema) .mutation(async ({ ctx, input }) => { - const { templateId, teamId, recipients, distributeDocument, customDocumentDataId } = input; + const { teamId } = ctx; + const { templateId, recipients, distributeDocument, customDocumentDataId } = input; const limits = await getServerLimits({ email: ctx.user.email, teamId }); @@ -246,15 +246,13 @@ export const templateRouter = router({ throw new Error('You have reached your document limit.'); } - const requestMetadata = extractNextApiRequestMetadata(ctx.req); - const document: Document = await createDocumentFromTemplate({ templateId, teamId, userId: ctx.user.id, recipients, customDocumentDataId, - requestMetadata, + requestMetadata: ctx.metadata, }); if (distributeDocument) { @@ -262,7 +260,7 @@ export const templateRouter = router({ documentId: document.id, userId: ctx.user.id, teamId, - requestMetadata, + requestMetadata: ctx.metadata, }).catch((err) => { console.error(err); @@ -278,18 +276,20 @@ export const templateRouter = router({ }), /** - * @public + * Leaving this endpoint as private for now until there is a use case for it. + * + * @private */ createDocumentFromDirectTemplate: maybeAuthenticatedProcedure - .meta({ - openapi: { - method: 'POST', - path: '/template/use', - summary: 'Use direct template', - description: 'Use a direct template to create a document', - tags: ['Template'], - }, - }) + // .meta({ + // openapi: { + // method: 'POST', + // path: '/template/direct/use', + // summary: 'Use direct template', + // description: 'Use a direct template to create a document', + // tags: ['Template'], + // }, + // }) .input(ZCreateDocumentFromDirectTemplateMutationSchema) .output(ZCreateDocumentFromDirectTemplateResponseSchema) .mutation(async ({ input, ctx }) => { @@ -302,8 +302,6 @@ export const templateRouter = router({ templateUpdatedAt, } = input; - const requestMetadata = extractNextApiRequestMetadata(ctx.req); - return await createDocumentFromDirectTemplate({ directRecipientName, directRecipientEmail, @@ -318,25 +316,7 @@ export const templateRouter = router({ email: ctx.user.email, } : undefined, - requestMetadata, - }); - }), - - /** - * @private - */ - setSigningOrderForTemplate: authenticatedProcedure - .input(ZSetSigningOrderForTemplateMutationSchema) - .mutation(async ({ input, ctx }) => { - const { templateId, teamId, signingOrder } = input; - - return await updateTemplateSettings({ - templateId, - teamId, - data: {}, - meta: { signingOrder }, - userId: ctx.user.id, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata, }); }), @@ -347,7 +327,7 @@ export const templateRouter = router({ .meta({ openapi: { method: 'POST', - path: '/template/{templateId}/direct/create', + path: '/template/direct/create', summary: 'Create direct link', description: 'Create a direct link for a template', tags: ['Template'], @@ -356,7 +336,8 @@ export const templateRouter = router({ .input(ZCreateTemplateDirectLinkMutationSchema) .output(ZCreateTemplateDirectLinkResponseSchema) .mutation(async ({ input, ctx }) => { - const { templateId, teamId, directRecipientId } = input; + const { teamId } = ctx; + const { templateId, directRecipientId } = input; const userId = ctx.user.id; @@ -370,7 +351,7 @@ export const templateRouter = router({ }); } - return await createTemplateDirectLink({ userId, templateId, directRecipientId }); + return await createTemplateDirectLink({ userId, teamId, templateId, directRecipientId }); }), /** @@ -379,8 +360,8 @@ export const templateRouter = router({ deleteTemplateDirectLink: authenticatedProcedure .meta({ openapi: { - method: 'POST', - path: '/template/{templateId}/direct/delete', + method: 'DELETE', + path: '/template/direct/{templateId}', summary: 'Delete direct link', description: 'Delete a direct link for a template', tags: ['Template'], @@ -389,11 +370,12 @@ export const templateRouter = router({ .input(ZDeleteTemplateDirectLinkMutationSchema) .output(z.void()) .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; const { templateId } = input; const userId = ctx.user.id; - await deleteTemplateDirectLink({ userId, templateId }); + await deleteTemplateDirectLink({ userId, teamId, templateId }); }), /** @@ -412,11 +394,12 @@ export const templateRouter = router({ .input(ZToggleTemplateDirectLinkMutationSchema) .output(ZToggleTemplateDirectLinkResponseSchema) .mutation(async ({ input, ctx }) => { + const { teamId } = ctx; const { templateId, enabled } = input; const userId = ctx.user.id; - return await toggleTemplateDirectLink({ userId, templateId, enabled }); + return await toggleTemplateDirectLink({ userId, teamId, templateId, enabled }); }), /** @@ -426,7 +409,7 @@ export const templateRouter = router({ .meta({ openapi: { method: 'POST', - path: '/template/{templateId}/move', + path: '/template/move', summary: 'Move template', description: 'Move a template to a team', tags: ['Template'], @@ -444,37 +427,4 @@ export const templateRouter = router({ userId, }); }), - - /** - * @private - */ - updateTemplateTypedSignatureSettings: authenticatedProcedure - .input(ZUpdateTemplateTypedSignatureSettingsMutationSchema) - .mutation(async ({ input, ctx }) => { - const { templateId, teamId, typedSignatureEnabled } = input; - - const template = await getTemplateById({ - id: templateId, - userId: ctx.user.id, - teamId, - }).catch(() => null); - - if (!template) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Template not found', - }); - } - - return await updateTemplateSettings({ - templateId, - teamId, - userId: ctx.user.id, - data: {}, - meta: { - typedSignatureEnabled, - }, - requestMetadata: extractNextApiRequestMetadata(ctx.req), - }); - }), }); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index ac68926c1..9b230eadd 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -1,25 +1,27 @@ import { z } from 'zod'; -import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n'; import { ZDocumentAccessAuthTypesSchema, ZDocumentActionAuthTypesSchema, } from '@documenso/lib/types/document-auth'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; -import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; -import { - DocumentDistributionMethod, - DocumentSigningOrder, - DocumentVisibility, - TemplateType, -} from '@documenso/prisma/client'; +import { DocumentSigningOrder, DocumentVisibility, TemplateType } from '@documenso/prisma/client'; +import { + ZDocumentMetaDateFormatSchema, + ZDocumentMetaDistributionMethodSchema, + ZDocumentMetaLanguageSchema, + ZDocumentMetaMessageSchema, + ZDocumentMetaRedirectUrlSchema, + ZDocumentMetaSubjectSchema, + ZDocumentMetaTimezoneSchema, + ZDocumentMetaTypedSignatureEnabledSchema, +} from '../document-router/schema'; import { ZSignFieldWithTokenMutationSchema } from '../field-router/schema'; export const ZCreateTemplateMutationSchema = z.object({ title: z.string().min(1).trim(), - teamId: z.number().optional(), templateDocumentDataId: z.string().min(1), }); @@ -34,119 +36,119 @@ export const ZCreateDocumentFromDirectTemplateMutationSchema = z.object({ export const ZCreateDocumentFromTemplateMutationSchema = z.object({ templateId: z.number(), - teamId: z.number().optional(), recipients: z .array( z.object({ - id: z.number(), + id: z.number().describe('The ID of the recipient in the template.'), email: z.string().email(), name: z.string().optional(), }), ) + .describe('The information of the recipients to create the document with.') .refine((recipients) => { const emails = recipients.map((signer) => signer.email); + return new Set(emails).size === emails.length; }, 'Recipients must have unique emails'), - distributeDocument: z.boolean().optional(), - customDocumentDataId: z.string().optional(), + distributeDocument: z + .boolean() + .describe('Whether to create the document as pending and distribute it to recipients.') + .optional(), + customDocumentDataId: z + .string() + .describe( + 'The data ID of an alternative PDF to use when creating the document. If not provided, the PDF attached to the template will be used.', + ) + .optional(), }); export const ZDuplicateTemplateMutationSchema = z.object({ templateId: z.number(), - teamId: z.number().optional(), }); export const ZCreateTemplateDirectLinkMutationSchema = z.object({ - templateId: z.number().min(1), - teamId: z.number().optional(), - directRecipientId: z.number().min(1).optional(), + templateId: z.number(), + directRecipientId: z + .number() + .describe( + 'The of the recipient in the current template to transform into the primary recipient when the template is used.', + ) + .optional(), }); export const ZDeleteTemplateDirectLinkMutationSchema = z.object({ - templateId: z.number().min(1), + templateId: z.number(), }); export const ZToggleTemplateDirectLinkMutationSchema = z.object({ - templateId: z.number().min(1), + templateId: z.number(), enabled: z.boolean(), }); export const ZDeleteTemplateMutationSchema = z.object({ - templateId: z.number().min(1), - teamId: z.number().optional(), + templateId: z.number(), }); export const MAX_TEMPLATE_PUBLIC_TITLE_LENGTH = 50; export const MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH = 256; -export const ZUpdateTemplateSettingsMutationSchema = z.object({ +export const ZUpdateTemplateRequestSchema = z.object({ templateId: z.number(), - teamId: z.number().min(1).optional(), - data: z.object({ - title: z.string().min(1).optional(), - externalId: z.string().nullish(), - visibility: z.nativeEnum(DocumentVisibility).optional(), - globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(), - globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(), - publicTitle: z.string().trim().min(1).max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH).optional(), - publicDescription: z - .string() - .trim() - .min(1) - .max(MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH) - .optional(), - type: z.nativeEnum(TemplateType).optional(), - language: z - .union([z.string(), z.enum(SUPPORTED_LANGUAGE_CODES)]) - .optional() - .default('en'), - }), + data: z + .object({ + title: z.string().min(1).optional(), + externalId: z.string().nullish(), + visibility: z.nativeEnum(DocumentVisibility).optional(), + globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(), + globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(), + publicTitle: z + .string() + .trim() + .min(1) + .max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH) + .describe( + 'The title of the template that will be displayed to the public. Only applicable for public templates.', + ) + .optional(), + publicDescription: z + .string() + .trim() + .min(1) + .max(MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH) + .describe( + 'The description of the template that will be displayed to the public. Only applicable for public templates.', + ) + .optional(), + type: z.nativeEnum(TemplateType).optional(), + }) + .optional(), meta: z .object({ - subject: z.string(), - message: z.string(), - timezone: z.string(), - dateFormat: z.string(), - distributionMethod: z.nativeEnum(DocumentDistributionMethod), - emailSettings: ZDocumentEmailSettingsSchema, - redirectUrl: z - .string() - .optional() - .refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), { - message: - 'Please enter a valid URL, make sure you include http:// or https:// part of the url.', - }), - language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(), - typedSignatureEnabled: z.boolean().optional(), + subject: ZDocumentMetaSubjectSchema.optional(), + message: ZDocumentMetaMessageSchema.optional(), + timezone: ZDocumentMetaTimezoneSchema.optional(), + dateFormat: ZDocumentMetaDateFormatSchema.optional(), + distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(), + emailSettings: ZDocumentEmailSettingsSchema.optional(), + redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(), + language: ZDocumentMetaLanguageSchema.optional(), + typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(), + signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), }) .optional(), }); -export const ZSetSigningOrderForTemplateMutationSchema = z.object({ - templateId: z.number(), - teamId: z.number().optional(), - signingOrder: z.nativeEnum(DocumentSigningOrder), -}); - export const ZFindTemplatesQuerySchema = ZFindSearchParamsSchema.extend({ - teamId: z.number().optional(), - type: z.nativeEnum(TemplateType).optional(), + type: z.nativeEnum(TemplateType).describe('Filter templates by type.').optional(), }); export const ZGetTemplateByIdQuerySchema = z.object({ templateId: z.number().min(1), - teamId: z.number().optional(), }); export const ZMoveTemplatesToTeamSchema = z.object({ - templateId: z.number(), - teamId: z.number(), -}); - -export const ZUpdateTemplateTypedSignatureSettingsMutationSchema = z.object({ - templateId: z.number(), - teamId: z.number().optional(), - typedSignatureEnabled: z.boolean(), + templateId: z.number().describe('The ID of the template to move to.'), + teamId: z.number().describe('The ID of the team to move the template to.'), }); export type TCreateTemplateMutationSchema = z.infer; diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index bbf08e854..5de7dddeb 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -5,6 +5,8 @@ import type { OpenApiMeta } from 'trpc-openapi'; import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error'; import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token'; +import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { TrpcContext } from './context'; @@ -62,8 +64,23 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => { ctx: { ...ctx, user: apiToken.user, + teamId: apiToken.teamId || undefined, session: null, - source: 'api', + metadata: { + ...ctx.metadata, + auditUser: apiToken.team + ? { + id: null, + email: null, + name: apiToken.team.name, + } + : { + id: apiToken.user.id, + email: apiToken.user.email, + name: apiToken.user.name, + }, + auth: 'api', + } satisfies ApiRequestMetadata, }, }); } @@ -71,7 +88,7 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => { if (!ctx.session) { throw new TRPCError({ code: 'UNAUTHORIZED', - message: 'You must be logged in to perform this action.', + message: 'Invalid session or API token.', }); } @@ -80,17 +97,39 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => { ...ctx, user: ctx.user, session: ctx.session, - source: 'app', + metadata: { + ...ctx.metadata, + auditUser: { + id: ctx.user.id, + name: ctx.user.name, + email: ctx.user.email, + }, + auth: 'session', + } satisfies ApiRequestMetadata, }, }); }); export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next }) => { + const requestMetadata = extractNextApiRequestMetadata(ctx.req); + return await next({ ctx: { ...ctx, user: ctx.user, session: ctx.session, + metadata: { + ...ctx.metadata, + auditUser: ctx.user + ? { + id: ctx.user.id, + name: ctx.user.name, + email: ctx.user.email, + } + : undefined, + requestMetadata, + auth: ctx.session ? 'session' : null, + } satisfies ApiRequestMetadata, }, }); }); @@ -117,6 +156,15 @@ export const adminMiddleware = t.middleware(async ({ ctx, next }) => { ...ctx, user: ctx.user, session: ctx.session, + metadata: { + ...ctx.metadata, + auditUser: { + id: ctx.user.id, + name: ctx.user.name, + email: ctx.user.email, + }, + auth: 'session', + } satisfies ApiRequestMetadata, }, }); }); diff --git a/packages/trpc/server/two-factor-authentication-router/router.ts b/packages/trpc/server/two-factor-authentication-router/router.ts index 5e4daab95..24f426caf 100644 --- a/packages/trpc/server/two-factor-authentication-router/router.ts +++ b/packages/trpc/server/two-factor-authentication-router/router.ts @@ -2,7 +2,6 @@ import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/d import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa'; import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa'; import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes'; -import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { authenticatedProcedure, router } from '../trpc'; import { @@ -28,7 +27,7 @@ export const twoFactorAuthenticationRouter = router({ return await enableTwoFactorAuthentication({ user, code, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata.requestMetadata, }); }), @@ -41,7 +40,7 @@ export const twoFactorAuthenticationRouter = router({ user, totpCode: input.totpCode, backupCode: input.backupCode, - requestMetadata: extractNextApiRequestMetadata(ctx.req), + requestMetadata: ctx.metadata.requestMetadata, }); }), diff --git a/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx b/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx index 6d0e12295..a9123486b 100644 --- a/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx +++ b/packages/ui/primitives/document-flow/field-item-advanced-settings.tsx @@ -2,9 +2,6 @@ import { forwardRef, useEffect, useState } from 'react'; -import { useParams } from 'next/navigation'; -import { usePathname } from 'next/navigation'; - import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; @@ -25,7 +22,6 @@ import { ZFieldMetaSchema, } from '@documenso/lib/types/field-meta'; import { FieldType } from '@documenso/prisma/client'; -import { trpc } from '@documenso/trpc/react'; import { useToast } from '@documenso/ui/primitives/use-toast'; import type { FieldFormType } from './add-fields'; @@ -146,50 +142,14 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => { export const FieldAdvancedSettings = forwardRef( ( - { - title, - description, - field, - fields, - onAdvancedSettings, - isDocumentPdfLoaded = true, - onSave, - teamId, - }, + { title, description, field, fields, onAdvancedSettings, isDocumentPdfLoaded = true, onSave }, ref, ) => { const { _ } = useLingui(); const { toast } = useToast(); - const params = useParams(); - const pathname = usePathname(); - const id = params?.id; - const isTemplatePage = pathname?.includes('template'); - const isDocumentPage = pathname?.includes('document'); const [errors, setErrors] = useState([]); - const { data: template } = trpc.template.getTemplateById.useQuery( - { - templateId: Number(id), - teamId, - }, - { - enabled: isTemplatePage, - }, - ); - - const { data: document } = trpc.document.getDocumentById.useQuery( - { - documentId: Number(id), - teamId, - }, - { - enabled: isDocumentPage, - }, - ); - - const doesFieldExist = (!!document || !!template) && field.nativeId !== undefined; - const fieldMeta = field?.fieldMeta; const localStorageKey = `field_${field.formId}_${field.type}`;