mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 00:43:40 +10:00
feat: add default recipients for teams and orgs (#2248)
This commit is contained in:
@@ -3,7 +3,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { TeamGlobalSettings } from '@prisma/client';
|
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 { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -17,14 +17,19 @@ import {
|
|||||||
isValidLanguageCode,
|
isValidLanguageCode,
|
||||||
} from '@documenso/lib/constants/i18n';
|
} from '@documenso/lib/constants/i18n';
|
||||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
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 {
|
import {
|
||||||
type TDocumentMetaDateFormat,
|
type TDocumentMetaDateFormat,
|
||||||
ZDocumentMetaTimezoneSchema,
|
ZDocumentMetaTimezoneSchema,
|
||||||
} from '@documenso/lib/types/document-meta';
|
} from '@documenso/lib/types/document-meta';
|
||||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
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 { Alert } from '@documenso/ui/primitives/alert';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +50,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@documenso/ui/primitives/select';
|
} 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
|
* Can't infer this from the schema since we need to keep the schema inside the component to allow
|
||||||
* it to be dynamic.
|
* it to be dynamic.
|
||||||
@@ -58,6 +67,7 @@ export type TDocumentPreferencesFormSchema = {
|
|||||||
includeSigningCertificate: boolean | null;
|
includeSigningCertificate: boolean | null;
|
||||||
includeAuditLog: boolean | null;
|
includeAuditLog: boolean | null;
|
||||||
signatureTypes: DocumentSignatureType[];
|
signatureTypes: DocumentSignatureType[];
|
||||||
|
defaultRecipients: TDefaultRecipients | null;
|
||||||
delegateDocumentOwnership: boolean | null;
|
delegateDocumentOwnership: boolean | null;
|
||||||
aiFeaturesEnabled: boolean | null;
|
aiFeaturesEnabled: boolean | null;
|
||||||
};
|
};
|
||||||
@@ -74,6 +84,7 @@ type SettingsSubset = Pick<
|
|||||||
| 'typedSignatureEnabled'
|
| 'typedSignatureEnabled'
|
||||||
| 'uploadSignatureEnabled'
|
| 'uploadSignatureEnabled'
|
||||||
| 'drawSignatureEnabled'
|
| 'drawSignatureEnabled'
|
||||||
|
| 'defaultRecipients'
|
||||||
| 'delegateDocumentOwnership'
|
| 'delegateDocumentOwnership'
|
||||||
| 'aiFeaturesEnabled'
|
| 'aiFeaturesEnabled'
|
||||||
>;
|
>;
|
||||||
@@ -94,6 +105,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { user, organisations } = useSession();
|
const { user, organisations } = useSession();
|
||||||
const currentOrganisation = useCurrentOrganisation();
|
const currentOrganisation = useCurrentOrganisation();
|
||||||
|
const optionalTeam = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||||
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
|
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
|
||||||
@@ -111,6 +123,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
}),
|
}),
|
||||||
|
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
|
||||||
delegateDocumentOwnership: z.boolean().nullable(),
|
delegateDocumentOwnership: z.boolean().nullable(),
|
||||||
aiFeaturesEnabled: z.boolean().nullable(),
|
aiFeaturesEnabled: z.boolean().nullable(),
|
||||||
});
|
});
|
||||||
@@ -128,6 +141,9 @@ export const DocumentPreferencesForm = ({
|
|||||||
includeSigningCertificate: settings.includeSigningCertificate,
|
includeSigningCertificate: settings.includeSigningCertificate,
|
||||||
includeAuditLog: settings.includeAuditLog,
|
includeAuditLog: settings.includeAuditLog,
|
||||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||||
|
defaultRecipients: settings.defaultRecipients
|
||||||
|
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
|
||||||
|
: null,
|
||||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
@@ -519,6 +535,94 @@ export const DocumentPreferencesForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="defaultRecipients"
|
||||||
|
render={({ field }) => {
|
||||||
|
const recipients = field.value ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Default Recipients</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
{canInherit && (
|
||||||
|
<Select
|
||||||
|
value={field.value === null ? '-1' : '0'}
|
||||||
|
onValueChange={(value) => field.onChange(value === '-1' ? null : [])}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Inherit from organisation</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={'0'}>
|
||||||
|
<Trans>Override organisation settings</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(field.value !== null || !canInherit) && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DefaultRecipientsMultiSelectCombobox
|
||||||
|
listValues={recipients}
|
||||||
|
onChange={field.onChange}
|
||||||
|
organisationId={!canInherit ? currentOrganisation.id : undefined}
|
||||||
|
teamId={canInherit ? optionalTeam?.id : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{recipients.map((recipient, index) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={recipient.email}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<AvatarWithText
|
||||||
|
avatarFallback={recipientAbbreviation(recipient)}
|
||||||
|
primaryText={
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{recipient.name || recipient.email}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
secondaryText={
|
||||||
|
recipient.name ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{recipient.email}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RecipientRoleSelect
|
||||||
|
value={recipient.role}
|
||||||
|
onValueChange={(role: RecipientRole) => {
|
||||||
|
field.onChange(
|
||||||
|
recipients.map((recipient, idx) =>
|
||||||
|
idx === index ? { ...recipient, role } : recipient,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>Recipients that will be automatically added to new documents.</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="delegateDocumentOwnership"
|
name="delegateDocumentOwnership"
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
|
||||||
|
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
|
||||||
|
|
||||||
|
type DefaultRecipientsMultiSelectComboboxProps = {
|
||||||
|
listValues: TDefaultRecipient[];
|
||||||
|
onChange: (_values: TDefaultRecipient[]) => 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 (
|
||||||
|
<MultiSelect
|
||||||
|
commandProps={{ label: _(msg`Select recipients`) }}
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={onSelectionChange}
|
||||||
|
placeholder={_(msg`Select recipients`)}
|
||||||
|
hideClearAllButton
|
||||||
|
hidePlaceholderWhenSelected
|
||||||
|
loadingIndicator={isLoading ? <p className="text-center text-sm">Loading...</p> : undefined}
|
||||||
|
emptyIndicator={<p className="text-center text-sm">No members found</p>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -306,7 +306,7 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
style={provided.draggableProps.style}
|
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' : ''
|
snapshot.isDragging ? 'shadow-md' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -332,7 +332,7 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
<p className="text-sm font-medium">{localFile.title}</p>
|
<p className="text-sm font-medium">{localFile.title}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-xs text-muted-foreground">
|
||||||
{localFile.isUploading ? (
|
{localFile.isUploading ? (
|
||||||
<Trans>Uploading</Trans>
|
<Trans>Uploading</Trans>
|
||||||
) : localFile.isError ? (
|
) : localFile.isError ? (
|
||||||
@@ -345,13 +345,13 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{localFile.isUploading && (
|
{localFile.isUploading && (
|
||||||
<div className="flex h-6 w-10 items-center justify-center">
|
<div className="flex h-6 w-10 items-center justify-center">
|
||||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{localFile.isError && (
|
{localFile.isError && (
|
||||||
<div className="flex h-6 w-10 items-center justify-center">
|
<div className="flex h-6 w-10 items-center justify-center">
|
||||||
<FileWarningIcon className="text-destructive h-4 w-4" />
|
<FileWarningIcon className="h-4 w-4 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ export default function DocumentEditSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
|
||||||
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
|
<div className="col-span-12 rounded-xl border-2 border-border bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7 dark:bg-background">
|
||||||
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
|
||||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
<Loader className="h-12 w-12 animate-spin text-documenso" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4">
|
<p className="mt-4 text-muted-foreground">
|
||||||
<Trans>Loading document...</Trans>
|
<Trans>Loading document...</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-background border-border col-span-12 rounded-xl border-2 before:rounded-xl lg:col-span-6 xl:col-span-5" />
|
<div className="col-span-12 rounded-xl border-2 border-border bg-background before:rounded-xl lg:col-span-6 xl:col-span-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
|
defaultRecipients,
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -83,6 +84,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
|
defaultRecipients,
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export default function DocumentEditPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
{recipients.length > 0 && (
|
||||||
<div className="text-muted-foreground flex items-center">
|
<div className="flex items-center text-muted-foreground">
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
<StackAvatarsWithTooltip
|
<StackAvatarsWithTooltip
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default function TeamsSettingsPage() {
|
|||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
|
defaultRecipients,
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -64,6 +65,7 @@ export default function TeamsSettingsPage() {
|
|||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
|
defaultRecipients,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
...(signatureTypes.length === 0
|
...(signatureTypes.length === 0
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
import type {
|
||||||
|
TCreateEnvelopePayload,
|
||||||
|
TCreateEnvelopeResponse,
|
||||||
|
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to set default recipients for a team
|
||||||
|
*/
|
||||||
|
const setTeamDefaultRecipients = async (
|
||||||
|
teamId: number,
|
||||||
|
defaultRecipients: Array<{ email: string; name: string; role: RecipientRole }>,
|
||||||
|
) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
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 { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||||
@@ -345,8 +346,22 @@ export const createEnvelope = async ({
|
|||||||
|
|
||||||
const firstEnvelopeItem = envelope.envelopeItems[0];
|
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(
|
await Promise.all(
|
||||||
(data.recipients || []).map(async (recipient) => {
|
allRecipients.map(async (recipient) => {
|
||||||
const recipientAuthOptions = createRecipientAuthOptions({
|
const recipientAuthOptions = createRecipientAuthOptions({
|
||||||
accessAuth: recipient.accessAuth ?? [],
|
accessAuth: recipient.accessAuth ?? [],
|
||||||
actionAuth: recipient.actionAuth ?? [],
|
actionAuth: recipient.actionAuth ?? [],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { OrganisationType } from '@prisma/client';
|
import { OrganisationType } from '@prisma/client';
|
||||||
import { OrganisationMemberRole } from '@prisma/client';
|
import { OrganisationMemberRole } from '@prisma/client';
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ export const createOrganisation = async ({
|
|||||||
const organisationSetting = await tx.organisationGlobalSettings.create({
|
const organisationSetting = await tx.organisationGlobalSettings.create({
|
||||||
data: {
|
data: {
|
||||||
...generateDefaultOrganisationSettings(),
|
...generateDefaultOrganisationSettings(),
|
||||||
|
defaultRecipients: Prisma.DbNull,
|
||||||
id: generateDatabaseId('org_setting'),
|
id: generateDatabaseId('org_setting'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
@@ -137,6 +142,7 @@ export const createTeam = async ({
|
|||||||
const teamSettings = await tx.teamGlobalSettings.create({
|
const teamSettings = await tx.teamGlobalSettings.create({
|
||||||
data: {
|
data: {
|
||||||
...generateDefaultTeamSettings(),
|
...generateDefaultTeamSettings(),
|
||||||
|
defaultRecipients: Prisma.DbNull,
|
||||||
id: generateDatabaseId('team_setting'),
|
id: generateDatabaseId('team_setting'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import { ZDefaultRecipientsSchema } from '../../types/default-recipients';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
||||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
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
|
// Key = original envelope item ID
|
||||||
// Value = duplicated envelope item ID.
|
// Value = duplicated envelope item ID.
|
||||||
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
|
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
|
||||||
@@ -515,7 +540,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
documentMetaId: documentMeta.id,
|
documentMetaId: documentMeta.id,
|
||||||
recipients: {
|
recipients: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: finalRecipients.map((recipient) => {
|
data: allFinalRecipients.map((recipient) => {
|
||||||
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
|
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
|
||||||
|
|
||||||
return {
|
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);
|
const recipient = envelope.recipients.find((recipient) => recipient.token === token);
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
|
|||||||
@@ -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<typeof ZDefaultRecipientSchema>;
|
||||||
|
|
||||||
|
export const ZDefaultRecipientsSchema = z.array(ZDefaultRecipientSchema);
|
||||||
|
|
||||||
|
export type TDefaultRecipients = z.infer<typeof ZDefaultRecipientsSchema>;
|
||||||
@@ -137,6 +137,7 @@ export const generateDefaultOrganisationSettings = (): Omit<
|
|||||||
// emailReplyToName: null,
|
// emailReplyToName: null,
|
||||||
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||||
|
|
||||||
|
defaultRecipients: null,
|
||||||
aiFeaturesEnabled: false,
|
aiFeaturesEnabled: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ export const extractInitials = (text: string) =>
|
|||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
export const recipientAbbreviation = (recipient: Recipient) => {
|
export const recipientAbbreviation = (recipient: Pick<Recipient, 'name' | 'email'>) => {
|
||||||
return extractInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase();
|
return extractInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
|||||||
emailReplyTo: null,
|
emailReplyTo: null,
|
||||||
// emailReplyToName: null,
|
// emailReplyToName: null,
|
||||||
|
|
||||||
|
defaultRecipients: null,
|
||||||
aiFeaturesEnabled: null,
|
aiFeaturesEnabled: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "defaultRecipients" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "defaultRecipients" JSONB;
|
||||||
@@ -808,7 +808,7 @@ enum OrganisationMemberInviteStatus {
|
|||||||
DECLINED
|
DECLINED
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';"])
|
||||||
model OrganisationGlobalSettings {
|
model OrganisationGlobalSettings {
|
||||||
id String @id
|
id String @id
|
||||||
organisation Organisation?
|
organisation Organisation?
|
||||||
@@ -826,6 +826,8 @@ model OrganisationGlobalSettings {
|
|||||||
uploadSignatureEnabled Boolean @default(true)
|
uploadSignatureEnabled Boolean @default(true)
|
||||||
drawSignatureEnabled Boolean @default(true)
|
drawSignatureEnabled Boolean @default(true)
|
||||||
|
|
||||||
|
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
|
||||||
|
|
||||||
emailId String?
|
emailId String?
|
||||||
email OrganisationEmail? @relation(fields: [emailId], references: [id])
|
email OrganisationEmail? @relation(fields: [emailId], references: [id])
|
||||||
|
|
||||||
@@ -842,7 +844,7 @@ model OrganisationGlobalSettings {
|
|||||||
aiFeaturesEnabled Boolean @default(false)
|
aiFeaturesEnabled Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';"])
|
||||||
model TeamGlobalSettings {
|
model TeamGlobalSettings {
|
||||||
id String @id
|
id String @id
|
||||||
team Team?
|
team Team?
|
||||||
@@ -861,6 +863,8 @@ model TeamGlobalSettings {
|
|||||||
uploadSignatureEnabled Boolean?
|
uploadSignatureEnabled Boolean?
|
||||||
drawSignatureEnabled Boolean?
|
drawSignatureEnabled Boolean?
|
||||||
|
|
||||||
|
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
|
||||||
|
|
||||||
emailId String?
|
emailId String?
|
||||||
email OrganisationEmail? @relation(fields: [emailId], references: [id])
|
email OrganisationEmail? @relation(fields: [emailId], references: [id])
|
||||||
|
|
||||||
|
|||||||
Vendored
+3
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-namespace */
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
|
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
|
||||||
import type {
|
import type {
|
||||||
TDocumentAuthOptions,
|
TDocumentAuthOptions,
|
||||||
TRecipientAuthOptions,
|
TRecipientAuthOptions,
|
||||||
@@ -26,6 +27,8 @@ declare global {
|
|||||||
type FieldMeta = TFieldMetaNotOptionalSchema;
|
type FieldMeta = TFieldMetaNotOptionalSchema;
|
||||||
|
|
||||||
type EnvelopeAttachmentType = TEnvelopeAttachmentType;
|
type EnvelopeAttachmentType = TEnvelopeAttachmentType;
|
||||||
|
|
||||||
|
type DefaultRecipient = TDefaultRecipient;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { OrganisationType } from '@prisma/client';
|
import { OrganisationType, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
@@ -36,6 +36,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
defaultRecipients,
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
@@ -145,6 +146,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
|
||||||
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
|
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
|
||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||||
|
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
import {
|
import {
|
||||||
ZDocumentMetaDateFormatSchema,
|
ZDocumentMetaDateFormatSchema,
|
||||||
@@ -22,6 +23,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
|||||||
typedSignatureEnabled: z.boolean().optional(),
|
typedSignatureEnabled: z.boolean().optional(),
|
||||||
uploadSignatureEnabled: z.boolean().optional(),
|
uploadSignatureEnabled: z.boolean().optional(),
|
||||||
drawSignatureEnabled: z.boolean().optional(),
|
drawSignatureEnabled: z.boolean().optional(),
|
||||||
|
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
|
||||||
delegateDocumentOwnership: z.boolean().nullish(),
|
delegateDocumentOwnership: z.boolean().nullish(),
|
||||||
|
|
||||||
// Branding related settings.
|
// Branding related settings.
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||||||
// emailReplyToName,
|
// emailReplyToName,
|
||||||
emailDocumentSettings,
|
emailDocumentSettings,
|
||||||
|
|
||||||
|
// Default recipients settings.
|
||||||
|
defaultRecipients,
|
||||||
// AI features settings.
|
// AI features settings.
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -165,6 +167,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||||||
// emailReplyToName,
|
// emailReplyToName,
|
||||||
emailDocumentSettings:
|
emailDocumentSettings:
|
||||||
emailDocumentSettings === null ? Prisma.DbNull : emailDocumentSettings,
|
emailDocumentSettings === null ? Prisma.DbNull : emailDocumentSettings,
|
||||||
|
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
|
||||||
|
|
||||||
// AI features settings.
|
// AI features settings.
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||||
|
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
import {
|
import {
|
||||||
ZDocumentMetaDateFormatSchema,
|
ZDocumentMetaDateFormatSchema,
|
||||||
@@ -40,6 +41,8 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
|
|||||||
// emailReplyToName: z.string().nullish(),
|
// emailReplyToName: z.string().nullish(),
|
||||||
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullish(),
|
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||||
|
|
||||||
|
// Default recipients settings.
|
||||||
|
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
|
||||||
// AI features settings.
|
// AI features settings.
|
||||||
aiFeaturesEnabled: z.boolean().nullish(),
|
aiFeaturesEnabled: z.boolean().nullish(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ export const AddSignersFormPartial = ({
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="text-muted-foreground ml-1 cursor-help">
|
<span className="ml-1 cursor-help text-muted-foreground">
|
||||||
<HelpCircle className="h-3.5 w-3.5" />
|
<HelpCircle className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -615,7 +615,7 @@ export const AddSignersFormPartial = ({
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="text-muted-foreground ml-1 cursor-help">
|
<span className="ml-1 cursor-help text-muted-foreground">
|
||||||
<HelpCircle className="h-3.5 w-3.5" />
|
<HelpCircle className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -666,7 +666,7 @@ export const AddSignersFormPartial = ({
|
|||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
className={cn('py-1', {
|
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,
|
snapshot.isDragging,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -946,7 +946,7 @@ export const AddSignersFormPartial = ({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
|
className="bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||||
disabled={isSubmitting || isUserAlreadyARecipient}
|
disabled={isSubmitting || isUserAlreadyARecipient}
|
||||||
onClick={() => onAddSelfSigner()}
|
onClick={() => onAddSelfSigner()}
|
||||||
>
|
>
|
||||||
@@ -965,7 +965,7 @@ export const AddSignersFormPartial = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
className="text-muted-foreground ml-2 text-sm"
|
className="ml-2 text-sm text-muted-foreground"
|
||||||
htmlFor="showAdvancedRecipientSettings"
|
htmlFor="showAdvancedRecipientSettings"
|
||||||
>
|
>
|
||||||
<Trans>Show advanced settings</Trans>
|
<Trans>Show advanced settings</Trans>
|
||||||
|
|||||||
Reference in New Issue
Block a user