From bb3e9583e48ef9f1238e276cc0bf487e5fdbbfcc Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 13 Jan 2026 11:32:00 +0200 Subject: [PATCH] feat: add default recipients for teams and orgs (#2248) --- .../forms/document-preferences-form.tsx | 106 ++++- ...efault-recipients-multiselect-combobox.tsx | 90 ++++ .../envelope-editor-upload-page.tsx | 8 +- .../skeletons/document-edit-skeleton.tsx | 8 +- .../o.$orgUrl.settings.document.tsx | 2 + .../documents.$id.legacy_editor.tsx | 2 +- .../t.$teamUrl+/settings.document.tsx | 2 + .../e2e/teams/default-recipients.spec.ts | 427 ++++++++++++++++++ .../server-only/envelope/create-envelope.ts | 17 +- .../organisation/create-organisation.ts | 3 +- packages/lib/server-only/team/create-team.ts | 8 +- .../template/create-document-from-template.ts | 29 +- packages/lib/types/default-recipients.ts | 14 + packages/lib/utils/organisations.ts | 1 + packages/lib/utils/recipient-formatter.ts | 2 +- packages/lib/utils/teams.ts | 1 + .../migration.sql | 5 + packages/prisma/schema.prisma | 8 +- packages/prisma/types/types.d.ts | 3 + .../update-organisation-settings.ts | 4 +- .../update-organisation-settings.types.ts | 2 + .../team-router/update-team-settings.ts | 3 + .../team-router/update-team-settings.types.ts | 3 + .../primitives/document-flow/add-signers.tsx | 10 +- 24 files changed, 734 insertions(+), 24 deletions(-) create mode 100644 apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx create mode 100644 packages/app-tests/e2e/teams/default-recipients.spec.ts create mode 100644 packages/lib/types/default-recipients.ts create mode 100644 packages/prisma/migrations/20251202122345_add_default_recipients/migration.sql diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx index c3d615f10..f8f70fa52 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -3,7 +3,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; import type { TeamGlobalSettings } from '@prisma/client'; -import { DocumentVisibility, OrganisationType } from '@prisma/client'; +import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -17,14 +17,19 @@ import { isValidLanguageCode, } from '@documenso/lib/constants/i18n'; import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; +import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients'; +import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema, } from '@documenso/lib/types/document-meta'; import { isPersonalLayout } from '@documenso/lib/utils/organisations'; +import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip'; +import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; import { Alert } from '@documenso/ui/primitives/alert'; +import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { Button } from '@documenso/ui/primitives/button'; import { Combobox } from '@documenso/ui/primitives/combobox'; import { @@ -45,6 +50,10 @@ import { SelectValue, } from '@documenso/ui/primitives/select'; +import { useOptionalCurrentTeam } from '~/providers/team'; + +import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox'; + /** * Can't infer this from the schema since we need to keep the schema inside the component to allow * it to be dynamic. @@ -58,6 +67,7 @@ export type TDocumentPreferencesFormSchema = { includeSigningCertificate: boolean | null; includeAuditLog: boolean | null; signatureTypes: DocumentSignatureType[]; + defaultRecipients: TDefaultRecipients | null; delegateDocumentOwnership: boolean | null; aiFeaturesEnabled: boolean | null; }; @@ -74,6 +84,7 @@ type SettingsSubset = Pick< | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled' + | 'defaultRecipients' | 'delegateDocumentOwnership' | 'aiFeaturesEnabled' >; @@ -94,6 +105,7 @@ export const DocumentPreferencesForm = ({ const { t } = useLingui(); const { user, organisations } = useSession(); const currentOrganisation = useCurrentOrganisation(); + const optionalTeam = useOptionalCurrentTeam(); const isPersonalLayoutMode = isPersonalLayout(organisations); const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL; @@ -111,6 +123,7 @@ export const DocumentPreferencesForm = ({ signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, { message: msg`At least one signature type must be enabled`.id, }), + defaultRecipients: ZDefaultRecipientsSchema.nullable(), delegateDocumentOwnership: z.boolean().nullable(), aiFeaturesEnabled: z.boolean().nullable(), }); @@ -128,6 +141,9 @@ export const DocumentPreferencesForm = ({ includeSigningCertificate: settings.includeSigningCertificate, includeAuditLog: settings.includeAuditLog, signatureTypes: extractTeamSignatureSettings({ ...settings }), + defaultRecipients: settings.defaultRecipients + ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) + : null, delegateDocumentOwnership: settings.delegateDocumentOwnership, aiFeaturesEnabled: settings.aiFeaturesEnabled, }, @@ -519,6 +535,94 @@ export const DocumentPreferencesForm = ({ )} /> + { + const recipients = field.value ?? []; + + return ( + + + Default Recipients + + + {canInherit && ( + + )} + + {(field.value !== null || !canInherit) && ( +
+ + + {recipients.map((recipient, index) => { + return ( +
+ + {recipient.name || recipient.email} + + } + secondaryText={ + recipient.name ? ( + + {recipient.email} + + ) : undefined + } + className="flex-1" + /> +
+ { + field.onChange( + recipients.map((recipient, idx) => + idx === index ? { ...recipient, role } : recipient, + ), + ); + }} + /> +
+
+ ); + })} +
+ )} + + + Recipients that will be automatically added to new documents. + +
+ ); + }} + /> + void; + teamId?: number; + organisationId?: string; +}; + +export const DefaultRecipientsMultiSelectCombobox = ({ + listValues, + onChange, + teamId, + organisationId, +}: DefaultRecipientsMultiSelectComboboxProps) => { + const { _ } = useLingui(); + + const { data: organisationData, isLoading: isLoadingOrganisation } = + trpc.organisation.member.find.useQuery( + { + organisationId: organisationId!, + query: '', + page: 1, + perPage: 100, + }, + { + enabled: !!organisationId, + }, + ); + + const { data: teamData, isLoading: isLoadingTeam } = trpc.team.member.find.useQuery( + { + teamId: teamId!, + query: '', + page: 1, + perPage: 100, + }, + { + enabled: !!teamId, + }, + ); + + const members = organisationId ? organisationData?.data : teamData?.data; + const isLoading = organisationId ? isLoadingOrganisation : isLoadingTeam; + + const options = members?.map((member) => ({ + value: member.email, + label: member.name ? `${member.name} (${member.email})` : member.email, + })); + + const value = listValues.map((recipient) => ({ + value: recipient.email, + label: recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email, + })); + + const onSelectionChange = (selected: Option[]) => { + const updatedRecipients = selected.map((option) => { + const existingRecipient = listValues.find((r) => r.email === option.value); + const member = members?.find((m) => m.email === option.value); + + return { + email: option.value, + name: member?.name || option.value, + role: existingRecipient?.role ?? RecipientRole.CC, + }; + }); + + onChange(updatedRecipients); + }; + + return ( + Loading...

: undefined} + emptyIndicator={

No members found

} + /> + ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index 439c821a0..2c953f833 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -306,7 +306,7 @@ export const EnvelopeEditorUploadPage = () => { ref={provided.innerRef} {...provided.draggableProps} style={provided.draggableProps.style} - className={`bg-accent/50 flex items-center justify-between rounded-lg p-3 transition-shadow ${ + className={`flex items-center justify-between rounded-lg bg-accent/50 p-3 transition-shadow ${ snapshot.isDragging ? 'shadow-md' : '' }`} > @@ -332,7 +332,7 @@ export const EnvelopeEditorUploadPage = () => {

{localFile.title}

)} -
+
{localFile.isUploading ? ( Uploading ) : localFile.isError ? ( @@ -345,13 +345,13 @@ export const EnvelopeEditorUploadPage = () => {
{localFile.isUploading && (
- +
)} {localFile.isError && (
- +
)} diff --git a/apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx b/apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx index 77e00fd92..1dc53458b 100644 --- a/apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx +++ b/apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -21,17 +21,17 @@ export default function DocumentEditSkeleton() {
-
+
- + -

+

Loading document...

-
+
); diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx index 684c83bd5..3ad5f0785 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx @@ -57,6 +57,7 @@ export default function OrganisationSettingsDocumentPage() { includeSigningCertificate, includeAuditLog, signatureTypes, + defaultRecipients, delegateDocumentOwnership, aiFeaturesEnabled, } = data; @@ -83,6 +84,7 @@ export default function OrganisationSettingsDocumentPage() { includeSenderDetails, includeSigningCertificate, includeAuditLog, + defaultRecipients, typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW), diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx index 0e633b28c..9b605f4bd 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.legacy_editor.tsx @@ -106,7 +106,7 @@ export default function DocumentEditPage() { /> {recipients.length > 0 && ( -
+
, +) => { + const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({ + where: { + team: { + id: teamId, + }, + }, + }); + + await prisma.teamGlobalSettings.update({ + where: { + id: teamSettings.id, + }, + data: { + defaultRecipients, + }, + }); +}; + +test.describe('Default Recipients', () => { + test('[DEFAULT_RECIPIENTS]: default recipients are added to documents created via UI', async ({ + page, + }) => { + const { team, owner } = await seedTeam({ + createTeamMembers: 2, + }); + + // Get a team member to set as default recipient + const teamMembers = await prisma.organisationMember.findMany({ + where: { + organisationId: team.organisationId, + userId: { + not: owner.id, + }, + }, + include: { + user: true, + }, + }); + + const defaultRecipientUser = teamMembers[0].user; + + // Set up default recipients for the team + await setTeamDefaultRecipients(team.id, [ + { + email: defaultRecipientUser.email, + name: defaultRecipientUser.name || defaultRecipientUser.email, + role: RecipientRole.CC, + }, + ]); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Upload document via UI - this triggers document creation with default recipients + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page + .locator('input[type=file]') + .nth(1) + .evaluate((e) => { + if (e instanceof HTMLInputElement) { + e.click(); + } + }), + ]); + + await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf')); + + // Wait to be redirected to the edit page (v2 envelope editor) + await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`)); + + // Extract document ID from URL + const urlParts = page.url().split('/'); + const documentId = urlParts.find((part) => part.startsWith('envelope_')); + + // Wait for the Recipients card to be visible (v2 envelope editor) + await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible(); + + await expect(page.getByTestId('signer-email-input').first()).not.toBeEmpty(); + + await page.getByRole('button', { name: 'Add Signer' }).click(); + + // Add a regular signer using the v2 editor + await page.getByTestId('signer-email-input').last().fill('regular-signer@documenso.com'); + await page + .getByPlaceholder(/Recipient/) + .first() + .fill('Regular Signer'); + + // Wait for autosave to complete + await page.waitForTimeout(3000); + + // Verify that default recipient is present in the database + await expect(async () => { + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + id: documentId, + }, + include: { + recipients: true, + }, + }); + + // Should have 2 recipients: the regular signer + the default recipient + expect(envelope.recipients.length).toBe(2); + + const defaultRecipient = envelope.recipients.find( + (r) => r.email.toLowerCase() === defaultRecipientUser.email.toLowerCase(), + ); + expect(defaultRecipient).toBeDefined(); + expect(defaultRecipient?.role).toBe(RecipientRole.CC); + + const regularSigner = envelope.recipients.find( + (r) => r.email === 'regular-signer@documenso.com', + ); + expect(regularSigner).toBeDefined(); + }).toPass(); + }); + + // TODO: Are we intending to allow default recipients to be removed from a document? + test.skip('[DEFAULT_RECIPIENTS]: default recipients cannot be removed from a document', async ({ + page, + }) => { + const { team, owner } = await seedTeam({ + createTeamMembers: 2, + }); + + // Get a team member to set as default recipient + const teamMembers = await prisma.organisationMember.findMany({ + where: { + organisationId: team.organisationId, + userId: { + not: owner.id, + }, + }, + include: { + user: true, + }, + }); + + const defaultRecipientUser = teamMembers[0].user; + + // Set up default recipients for the team + await setTeamDefaultRecipients(team.id, [ + { + email: defaultRecipientUser.email, + name: defaultRecipientUser.name || defaultRecipientUser.email, + role: RecipientRole.CC, + }, + ]); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Upload document via UI - this triggers document creation with default recipients + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page + .locator('input[type=file]') + .nth(1) + .evaluate((e) => { + if (e instanceof HTMLInputElement) { + e.click(); + } + }), + ]); + + await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf')); + + // Wait to be redirected to the edit page (v2 envelope editor) + await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`)); + + // Extract document ID from URL + const urlParts = page.url().split('/'); + const documentId = urlParts.find((part) => part.startsWith('envelope_')); + + // Replace the default recipient with a regular signer + await page.getByTestId('signer-email-input').first().fill('regular-signer@documenso.com'); + await page + .getByPlaceholder(/Recipient/) + .first() + .fill('Regular Signer'); + + // Wait for autosave to complete + await page.waitForTimeout(3000); + + // Wait for recipients to be saved + await expect(async () => { + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + id: documentId, + }, + include: { + recipients: true, + }, + }); + expect(envelope.recipients.length).toBe(2); + }).toPass(); + + // Verify that the default recipient's remove button is disabled + // In the v2 editor, default recipients should have a disabled remove button + // Find the fieldset containing the default recipient's email and check if its remove button is disabled + const defaultRecipientRow = page.locator('fieldset').filter({ + hasText: defaultRecipientUser.email, + }); + + // The default recipient row should exist and have a disabled remove button + await expect(defaultRecipientRow).toHaveCount(1); + const removeButton = defaultRecipientRow.getByTestId('remove-signer-button'); + await expect(removeButton).toBeDisabled(); + }); + + test('[DEFAULT_RECIPIENTS]: documents created via API have default recipients', async ({ + request, + }) => { + const { team, owner } = await seedTeam({ + createTeamMembers: 2, + }); + + // Get a team member to set as default recipient + const teamMembers = await prisma.organisationMember.findMany({ + where: { + organisationId: team.organisationId, + userId: { + not: owner.id, + }, + }, + include: { + user: true, + }, + }); + + const defaultRecipientUser = teamMembers[0].user; + + // Set up default recipients for the team + await setTeamDefaultRecipients(team.id, [ + { + email: defaultRecipientUser.email, + name: defaultRecipientUser.name || defaultRecipientUser.email, + role: RecipientRole.CC, + }, + ]); + + // Create API token + const { token } = await createApiToken({ + userId: owner.id, + teamId: team.id, + tokenName: 'test-token', + expiresIn: null, + }); + + // Create envelope via API + const payload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'Test Document with Default Recipients', + recipients: [ + { + email: 'api-recipient@documenso.com', + name: 'API Recipient', + role: RecipientRole.SIGNER, + }, + ], + }; + + const formData = new FormData(); + formData.append('payload', JSON.stringify(payload)); + + const pdfData = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf')); + formData.append('files', new File([pdfData], 'test.pdf', { type: 'application/pdf' })); + + const res = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const response = (await res.json()) as TCreateEnvelopeResponse; + + // Verify the envelope has both the API recipient and the default recipient + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { + id: response.id, + }, + include: { + recipients: true, + }, + }); + + expect(envelope.recipients.length).toBe(2); + + const apiRecipient = envelope.recipients.find((r) => r.email === 'api-recipient@documenso.com'); + expect(apiRecipient).toBeDefined(); + expect(apiRecipient?.role).toBe(RecipientRole.SIGNER); + + const defaultRecipient = envelope.recipients.find( + (r) => r.email.toLowerCase() === defaultRecipientUser.email.toLowerCase(), + ); + expect(defaultRecipient).toBeDefined(); + expect(defaultRecipient?.role).toBe(RecipientRole.CC); + }); + + test('[DEFAULT_RECIPIENTS]: documents created from template have default recipients', async ({ + page, + }) => { + const { team, owner } = await seedTeam({ + createTeamMembers: 2, + }); + + // Get a team member to set as default recipient + const teamMembers = await prisma.organisationMember.findMany({ + where: { + organisationId: team.organisationId, + userId: { + not: owner.id, + }, + }, + include: { + user: true, + }, + }); + + const defaultRecipientUser = teamMembers[0].user; + + // Set up default recipients for the team + await setTeamDefaultRecipients(team.id, [ + { + email: defaultRecipientUser.email, + name: defaultRecipientUser.name || defaultRecipientUser.email, + role: RecipientRole.CC, + }, + ]); + + // Create a template + const template = await seedBlankTemplate(owner, team.id); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/templates/${template.id}/edit`, + }); + + // Set template title + await page.getByLabel('Title').fill('Template with Default Recipients'); + + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible(); + + // Add a template recipient + await page.getByPlaceholder('Email').fill('template-recipient@documenso.com'); + await page.getByPlaceholder('Name').fill('Template Recipient'); + + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + + await page.getByRole('button', { name: 'Save template' }).click(); + + // Use template to create document + await page.waitForURL(`/t/${team.url}/templates`); + await page.getByRole('button', { name: 'Use Template' }).click(); + await page.getByRole('button', { name: 'Create as draft' }).click(); + + // Wait for document to be created + await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`)); + + const documentId = page.url().split('/').pop(); + + // Verify the document has both the template recipient and the default recipient + const document = await prisma.envelope.findFirstOrThrow({ + where: { + id: documentId, + }, + include: { + recipients: true, + }, + }); + + expect(document.recipients.length).toBe(2); + + const templateRecipient = document.recipients.find( + (r) => r.email === 'template-recipient@documenso.com', + ); + expect(templateRecipient).toBeDefined(); + + const defaultRecipient = document.recipients.find( + (r) => r.email.toLowerCase() === defaultRecipientUser.email.toLowerCase(), + ); + expect(defaultRecipient).toBeDefined(); + expect(defaultRecipient?.role).toBe(RecipientRole.CC); + }); +}); diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 76902a596..27d3c1e4a 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -11,6 +11,7 @@ import { import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf'; +import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { nanoid, prefixedId } from '@documenso/lib/universal/id'; @@ -345,8 +346,22 @@ export const createEnvelope = async ({ const firstEnvelopeItem = envelope.envelopeItems[0]; + const defaultRecipients = settings.defaultRecipients + ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) + : []; + + const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map( + (recipient) => ({ + email: recipient.email, + name: recipient.name, + role: recipient.role, + }), + ); + + const allRecipients = [...(data.recipients || []), ...mappedDefaultRecipients]; + await Promise.all( - (data.recipients || []).map(async (recipient) => { + allRecipients.map(async (recipient) => { const recipientAuthOptions = createRecipientAuthOptions({ accessAuth: recipient.accessAuth ?? [], actionAuth: recipient.actionAuth ?? [], diff --git a/packages/lib/server-only/organisation/create-organisation.ts b/packages/lib/server-only/organisation/create-organisation.ts index 67ce18533..2bbcc582b 100644 --- a/packages/lib/server-only/organisation/create-organisation.ts +++ b/packages/lib/server-only/organisation/create-organisation.ts @@ -1,4 +1,4 @@ -import type { Prisma } from '@prisma/client'; +import { Prisma } from '@prisma/client'; import { OrganisationType } from '@prisma/client'; import { OrganisationMemberRole } from '@prisma/client'; @@ -63,6 +63,7 @@ export const createOrganisation = async ({ const organisationSetting = await tx.organisationGlobalSettings.create({ data: { ...generateDefaultOrganisationSettings(), + defaultRecipients: Prisma.DbNull, id: generateDatabaseId('org_setting'), }, }); diff --git a/packages/lib/server-only/team/create-team.ts b/packages/lib/server-only/team/create-team.ts index b8ebb0a25..3e74aace5 100644 --- a/packages/lib/server-only/team/create-team.ts +++ b/packages/lib/server-only/team/create-team.ts @@ -1,4 +1,9 @@ -import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client'; +import { + OrganisationGroupType, + OrganisationMemberRole, + Prisma, + TeamMemberRole, +} from '@prisma/client'; import { match } from 'ts-pattern'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; @@ -137,6 +142,7 @@ export const createTeam = async ({ const teamSettings = await tx.teamGlobalSettings.create({ data: { ...generateDefaultTeamSettings(), + defaultRecipients: Prisma.DbNull, id: generateDatabaseId('team_setting'), }, }); 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 daf04f1e5..bea4ea690 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -19,6 +19,7 @@ import { prisma } from '@documenso/prisma'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; import type { SupportedLanguageCodes } from '../../constants/i18n'; import { AppError, AppErrorCode } from '../../errors/app-error'; +import { ZDefaultRecipientsSchema } from '../../types/default-recipients'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import type { TDocumentEmailSettings } from '../../types/document-email'; @@ -396,6 +397,30 @@ export const createDocumentFromTemplate = async ({ }; }); + const defaultRecipients = settings.defaultRecipients + ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) + : []; + + const defaultRecipientsFinal: FinalRecipient[] = defaultRecipients.map((recipient) => { + const authOptions = ZRecipientAuthOptionsSchema.parse({}); + + return { + templateRecipientId: -1, + fields: [], + name: recipient.name || recipient.email, + email: recipient.email, + role: recipient.role, + signingOrder: null, + authOptions: createRecipientAuthOptions({ + accessAuth: authOptions.accessAuth, + actionAuth: authOptions.actionAuth, + }), + token: nanoid(), + }; + }); + + const allFinalRecipients = [...finalRecipients, ...defaultRecipientsFinal]; + // Key = original envelope item ID // Value = duplicated envelope item ID. const oldEnvelopeItemToNewEnvelopeItemIdMap: Record = {}; @@ -515,7 +540,7 @@ export const createDocumentFromTemplate = async ({ documentMetaId: documentMeta.id, recipients: { createMany: { - data: finalRecipients.map((recipient) => { + data: allFinalRecipients.map((recipient) => { const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions); return { @@ -596,7 +621,7 @@ export const createDocumentFromTemplate = async ({ } } - Object.values(finalRecipients).forEach(({ token, fields }) => { + Object.values(allFinalRecipients).forEach(({ token, fields }) => { const recipient = envelope.recipients.find((recipient) => recipient.token === token); if (!recipient) { diff --git a/packages/lib/types/default-recipients.ts b/packages/lib/types/default-recipients.ts new file mode 100644 index 000000000..8bdbb54c0 --- /dev/null +++ b/packages/lib/types/default-recipients.ts @@ -0,0 +1,14 @@ +import { RecipientRole } from '@prisma/client'; +import { z } from 'zod'; + +export const ZDefaultRecipientSchema = z.object({ + email: z.string().email(), + name: z.string(), + role: z.nativeEnum(RecipientRole), +}); + +export type TDefaultRecipient = z.infer; + +export const ZDefaultRecipientsSchema = z.array(ZDefaultRecipientSchema); + +export type TDefaultRecipients = z.infer; diff --git a/packages/lib/utils/organisations.ts b/packages/lib/utils/organisations.ts index 4d9113785..a75feb18b 100644 --- a/packages/lib/utils/organisations.ts +++ b/packages/lib/utils/organisations.ts @@ -137,6 +137,7 @@ export const generateDefaultOrganisationSettings = (): Omit< // emailReplyToName: null, emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS, + defaultRecipients: null, aiFeaturesEnabled: false, }; }; diff --git a/packages/lib/utils/recipient-formatter.ts b/packages/lib/utils/recipient-formatter.ts index 913ca4b10..efda74817 100644 --- a/packages/lib/utils/recipient-formatter.ts +++ b/packages/lib/utils/recipient-formatter.ts @@ -7,6 +7,6 @@ export const extractInitials = (text: string) => .slice(0, 2) .join(''); -export const recipientAbbreviation = (recipient: Recipient) => { +export const recipientAbbreviation = (recipient: Pick) => { return extractInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase(); }; diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts index c80226eaf..d1274b613 100644 --- a/packages/lib/utils/teams.ts +++ b/packages/lib/utils/teams.ts @@ -204,6 +204,7 @@ export const generateDefaultTeamSettings = (): Omit - + @@ -615,7 +615,7 @@ export const AddSignersFormPartial = ({ - + @@ -666,7 +666,7 @@ export const AddSignersFormPartial = ({ {...provided.draggableProps} {...provided.dragHandleProps} className={cn('py-1', { - 'bg-widget-foreground pointer-events-none rounded-md pt-2': + 'pointer-events-none rounded-md bg-widget-foreground pt-2': snapshot.isDragging, })} > @@ -946,7 +946,7 @@ export const AddSignersFormPartial = ({