Compare commits

..

2 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan
c560b9e9e3 feat: add restore deleted document dialog 2025-03-13 22:09:07 +00:00
Ephraim Atta-Duncan
27cd8f9c25 feat: deleted documents bin 2025-03-13 19:45:11 +00:00
57 changed files with 746 additions and 1455 deletions

View File

@@ -1,9 +1,4 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -14,208 +9,64 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
export type NextSigner = {
name: string;
email: string;
};
type ConfirmationDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: (nextSigner?: NextSigner) => void;
onConfirm: () => void;
hasUninsertedFields: boolean;
isSubmitting: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: NextSigner;
};
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
export function AssistantConfirmationDialog({
isOpen,
onClose,
onConfirm,
hasUninsertedFields,
isSubmitting,
allowDictateNextSigner = false,
defaultNextSigner,
}: ConfirmationDialogProps) {
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: zodResolver(ZNextSignerFormSchema),
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const onOpenChange = () => {
if (form.formState.isSubmitting) {
if (isSubmitting) {
return;
}
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
setIsEditingNextSigner(false);
onClose();
};
const onFormSubmit = async (data: TNextSignerFormSchema) => {
if (allowDictateNextSigner && data.name && data.email) {
await onConfirm({
name: data.name,
email: data.email,
});
} else {
await onConfirm();
}
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
disabled={form.formState.isSubmitting || isSubmitting}
className="border-none p-0"
>
<DialogHeader>
<DialogTitle>
<Trans>Complete Document</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Are you sure you want to complete the document? This action cannot be undone.
Please ensure that you have completed prefilling all relevant fields before
proceeding.
Are you sure you want to complete the document? This action cannot be undone. Please
ensure that you have completed prefilling all relevant fields before proceeding.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="mt-4 flex flex-col gap-4">
{allowDictateNextSigner && (
<div className="space-y-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
<div className="flex flex-col gap-4">
<DocumentSigningDisclosure />
</div>
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
type="submit"
variant={hasUninsertedFields ? 'destructive' : 'default'}
disabled={form.formState.isSubmitting || !isNextSignerValid}
loading={form.formState.isSubmitting}
onClick={onConfirm}
disabled={isSubmitting}
loading={isSubmitting}
>
{form.formState.isSubmitting ? (
<Trans>Submitting...</Trans>
) : hasUninsertedFields ? (
<Trans>Proceed</Trans>
) : (
<Trans>Continue</Trans>
)}
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@@ -162,7 +162,16 @@ export const DocumentDeleteDialog = ({
</ul>
</AlertDescription>
))
.exhaustive()}
// DocumentStatus.REJECTED isnt working currently so this is a fallback to prevent 500 error.
// The union should work but currently its not
.otherwise(() => (
<AlertDescription>
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
</AlertDescription>
))}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">

View File

@@ -0,0 +1,119 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DocumentRestoreDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onRestore?: () => Promise<void> | void;
documentTitle: string;
teamId?: number;
canManageDocument: boolean;
};
export const DocumentRestoreDialog = ({
id,
open,
onOpenChange,
onRestore,
documentTitle,
canManageDocument,
}: DocumentRestoreDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
const { mutateAsync: restoreDocument, isPending } =
trpcReact.document.restoreDocument.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: _(msg`Document restored`),
description: _(msg`"${documentTitle}" has been successfully restored`),
duration: 5000,
});
await onRestore?.();
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be restored at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Restore Document</Trans>
</DialogTitle>
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to restore <strong>"{documentTitle}"</strong>
</Trans>
) : (
<Trans>
You are about to unhide <strong>"{documentTitle}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" className="-mt-1">
<AlertDescription>
{canManageDocument ? (
<Trans>
The document will be restored to your account and will be available in your
documents list.
</Trans>
) : (
<Trans>
The document will be unhidden from your account and will be available in your
documents list.
</Trans>
)}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
loading={isPending}
onClick={() => void restoreDocument({ documentId: id })}
>
<Trans>Restore</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -531,27 +531,6 @@ export const SignUpForm = ({
</div>
</form>
</Form>
<p className="text-muted-foreground mt-6 text-xs">
<Trans>
By proceeding, you agree to our{' '}
<Link
to="https://documen.so/terms"
target="_blank"
className="text-documenso-700 duration-200 hover:opacity-70"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
to="https://documen.so/privacy"
target="_blank"
className="text-documenso-700 duration-200 hover:opacity-70"
>
Privacy Policy
</Link>
.
</Trans>
</p>
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient, Signature } from '@prisma/client';
@@ -170,55 +170,6 @@ export const DirectTemplateSigningForm = ({
// Do not reset to false since we do a redirect.
};
useEffect(() => {
const updatedFields = [...localFields];
localFields.forEach((field) => {
const index = updatedFields.findIndex((f) => f.id === field.id);
let value = '';
match(field.type)
.with(FieldType.TEXT, () => {
const meta = field.fieldMeta ? ZTextFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.text ?? '';
}
})
.with(FieldType.NUMBER, () => {
const meta = field.fieldMeta ? ZNumberFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.value ?? '';
}
})
.with(FieldType.DROPDOWN, () => {
const meta = field.fieldMeta ? ZDropdownFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.defaultValue ?? '';
}
});
if (value) {
const signedValue = {
token: directRecipient.token,
fieldId: field.id,
value,
};
updatedFields[index] = {
...field,
customText: value,
inserted: true,
signedValue,
};
}
});
setLocalFields(updatedFields);
}, []);
return (
<DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />

View File

@@ -97,10 +97,6 @@ export const DocumentSigningCheckboxField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => {
try {
if (!isLengthConditionMet) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
@@ -198,30 +194,18 @@ export const DocumentSigningCheckboxField = ({
setCheckedValues(updatedValues);
const removePayload: TRemovedSignedFieldWithTokenMutationSchema = {
await removeSignedFieldWithToken({
token: recipient.token,
fieldId: field.id,
};
});
if (onUnsignField) {
await onUnsignField(removePayload);
} else {
await removeSignedFieldWithToken(removePayload);
}
if (updatedValues.length > 0 && shouldAutoSignField) {
const signPayload: TSignFieldWithTokenMutationSchema = {
if (updatedValues.length > 0) {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: toCheckboxValue(updatedValues),
isBase64: true,
};
if (onSignField) {
await onSignField(signPayload);
} else {
await signFieldWithToken(signPayload);
}
});
}
} catch (err) {
console.error(err);

View File

@@ -1,12 +1,9 @@
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { Button } from '@documenso/ui/primitives/button';
@@ -17,15 +14,6 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
@@ -34,22 +22,10 @@ export type DocumentSigningCompleteDialogProps = {
documentTitle: string;
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
role: RecipientRole;
disabled?: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: {
name: string;
email: string;
};
};
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
export const DocumentSigningCompleteDialog = ({
isSubmitting,
@@ -59,54 +35,19 @@ export const DocumentSigningCompleteDialog = ({
onSignatureComplete,
role,
disabled = false,
allowDictateNextSigner = false,
defaultNextSigner,
}: DocumentSigningCompleteDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
const handleOpenChange = (open: boolean) => {
if (form.formState.isSubmitting || !isComplete) {
if (isSubmitting || !isComplete) {
return;
}
if (open) {
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
}
setIsEditingNextSigner(false);
setShowDialog(open);
};
const onFormSubmit = async (data: TNextSignerFormSchema) => {
console.log('data', data);
console.log('form.formState.errors', form.formState.errors);
try {
if (allowDictateNextSigner && data.name && data.email) {
await onSignatureComplete({ name: data.name, email: data.email });
} else {
await onSignatureComplete();
}
} catch (error) {
console.error('Error completing signature:', error);
}
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return (
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
@@ -130,9 +71,6 @@ export const DocumentSigningCompleteDialog = ({
</DialogTrigger>
<DialogContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{match(role)
@@ -205,95 +143,27 @@ export const DocumentSigningCompleteDialog = ({
))}
</div>
{allowDictateNextSigner && (
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button
type="button"
className="mt-2"
variant="outline"
size="sm"
onClick={() => setIsEditingNextSigner((prev) => !prev)}
>
<Trans>Update Recipient</Trans>
</Button>
</div>
)}
{isEditingNextSigner && (
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
<DialogFooter className="mt-4">
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
onClick={() => {
setShowDialog(false);
}}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
type="button"
className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting}
disabled={!isComplete}
loading={isSubmitting}
onClick={onSignatureComplete}
>
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
@@ -305,9 +175,6 @@ export const DocumentSigningCompleteDialog = ({
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@@ -1,13 +1,11 @@
import { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
@@ -27,20 +25,10 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import {
AssistantConfirmationDialog,
type NextSigner,
} from '../../dialogs/assistant-confirmation-dialog';
import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { useRequiredDocumentSigningContext } from './document-signing-provider';
export const ZSigningFormSchema = z.object({
name: z.string().min(1, 'Name is required').optional(),
email: z.string().email('Invalid email address').optional(),
});
export type TSigningFormSchema = z.infer<typeof ZSigningFormSchema>;
export type DocumentSigningFormProps = {
document: DocumentAndSender;
recipient: Recipient;
@@ -87,9 +75,7 @@ export const DocumentSigningForm = ({
},
});
const { handleSubmit, formState } = useForm<TSigningFormSchema>({
resolver: zodResolver(ZSigningFormSchema),
});
const { handleSubmit, formState } = useForm();
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
@@ -114,8 +100,7 @@ export const DocumentSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation);
};
const onFormSubmit = async (data: TSigningFormSchema) => {
try {
const onFormSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
@@ -128,22 +113,7 @@ export const DocumentSigningForm = ({
return;
}
const nextSigner =
data.email && data.name
? {
email: data.email,
name: data.name,
}
: undefined;
await completeDocument(undefined, nextSigner);
} catch (error) {
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'An error occurred while signing',
variant: 'destructive',
});
}
await completeDocument();
};
const onAssistantFormSubmit = () => {
@@ -154,11 +124,11 @@ export const DocumentSigningForm = ({
setIsConfirmationDialogOpen(true);
};
const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => {
const handleAssistantConfirmDialogSubmit = async () => {
setIsAssistantSubmitting(true);
try {
await completeDocument(undefined, nextSigner);
await completeDocument();
} catch (err) {
toast({
title: 'Error',
@@ -171,18 +141,12 @@ export const DocumentSigningForm = ({
}
};
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
await completeDocumentWithToken({
token: recipient.token,
documentId: document.id,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocumentWithToken(payload);
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
@@ -197,31 +161,6 @@ export const DocumentSigningForm = ({
}
};
const nextRecipient = useMemo(() => {
if (
!document.documentMeta?.signingOrder ||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
) {
return undefined;
}
const sortedRecipients = allRecipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
console.log('nextRecipient', nextRecipient);
return (
<div
className={cn(
@@ -271,19 +210,12 @@ export const DocumentSigningForm = ({
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
disabled={!isRecipientsTurn}
/>
</div>
</div>
@@ -374,14 +306,6 @@ export const DocumentSigningForm = ({
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</form>
</>
@@ -452,9 +376,8 @@ export const DocumentSigningForm = ({
</div>
)}
</div>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
@@ -467,25 +390,16 @@ export const DocumentSigningForm = ({
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting || isAssistantSubmitting}
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
disabled={!isRecipientsTurn}
/>
</div>
</fieldset>
</form>
</>
)}

View File

@@ -40,9 +40,9 @@ import { DocumentReadOnlyFields } from '~/components/general/document/document-r
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
export type DocumentSigningPageViewProps = {
recipient: RecipientWithFields;
export type SigningPageViewProps = {
document: DocumentAndSender;
recipient: RecipientWithFields;
fields: Field[];
completedFields: CompletedField[];
isRecipientsTurn: boolean;
@@ -50,13 +50,13 @@ export type DocumentSigningPageViewProps = {
};
export const DocumentSigningPageView = ({
recipient,
document,
recipient,
fields,
completedFields,
isRecipientsTurn,
allRecipients = [],
}: DocumentSigningPageViewProps) => {
}: SigningPageViewProps) => {
const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);

View File

@@ -38,11 +38,6 @@ export const DocumentSigningRecipientProvider = ({
recipient,
targetSigner = null,
}: DocumentSigningRecipientProviderProps) => {
// console.log({
// recipient,
// targetSigner,
// isAssistantMode: !!targetSigner,
// });
return (
<DocumentSigningRecipientContext.Provider
value={{

View File

@@ -31,7 +31,10 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZRejectDocumentFormSchema = z.object({
reason: z.string().max(500, msg`Reason must be less than 500 characters`),
reason: z
.string()
.min(5, msg`Please provide a reason`)
.max(500, msg`Reason must be less than 500 characters`),
});
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;

View File

@@ -71,7 +71,7 @@ export const DocumentEditForm = ({
const { recipients, fields } = document;
const { mutateAsync: updateDocumentSettings } = trpc.document.setSettingsForDocument.useMutation({
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
@@ -176,7 +176,7 @@ export const DocumentEditForm = ({
try {
const { timezone, dateFormat, redirectUrl, language } = data.meta;
await updateDocumentSettings({
await updateDocument({
documentId: document.id,
data: {
title: data.title,
@@ -213,13 +213,6 @@ export const DocumentEditForm = ({
signingOrder: data.signingOrder,
}),
updateDocumentSettings({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({
documentId: document.id,
recipients: data.signers.map((signer) => ({
@@ -249,7 +242,7 @@ export const DocumentEditForm = ({
fields: data.fields,
});
await updateDocumentSettings({
await updateDocument({
documentId: document.id,
meta: {
@@ -372,7 +365,6 @@ export const DocumentEditForm = ({
documentFlow={documentFlow.signers}
recipients={recipients}
signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit}

View File

@@ -25,7 +25,7 @@ export const DocumentPageViewInformation = ({
const { _, i18n } = useLingui();
const documentInformation = useMemo(() => {
return [
const documentInfo = [
{
description: msg`Uploaded by`,
value:
@@ -44,6 +44,19 @@ export const DocumentPageViewInformation = ({
.toRelative(),
},
];
if (document.deletedAt) {
documentInfo.push({
description: msg`Deleted`,
value:
document.deletedAt &&
DateTime.fromJSDate(document.deletedAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toFormat('MMMM d, yyyy'),
});
}
return documentInfo;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, document, userId]);

View File

@@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
import { CheckCircle2, Clock, File, Trash, XCircle } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -36,11 +36,11 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
icon: File,
color: 'text-yellow-500 dark:text-yellow-200',
},
REJECTED: {
label: msg`Rejected`,
labelExtended: msg`Document rejected`,
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
DELETED: {
label: msg`Deleted`,
labelExtended: msg`Document deleted`,
icon: Trash,
color: 'text-red-700 dark:text-red-500',
},
INBOX: {
label: msg`Inbox`,
@@ -53,6 +53,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
labelExtended: msg`Document All`,
color: 'text-muted-foreground',
},
REJECTED: {
label: msg`Rejected`,
labelExtended: msg`Document rejected`,
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
},
};
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {

View File

@@ -161,7 +161,6 @@ export const TemplateEditForm = ({
templateId: template.id,
meta: {
signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
@@ -272,7 +271,6 @@ export const TemplateEditForm = ({
recipients={recipients}
fields={fields}
signingOrder={template.templateMeta?.signingOrder}
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise}

View File

@@ -15,6 +15,7 @@ import {
MoreHorizontal,
MoveRight,
Pencil,
RotateCcw,
Share,
Trash2,
} from 'lucide-react';
@@ -39,6 +40,7 @@ import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialo
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentMoveDialog } from '~/components/dialogs/document-move-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { DocumentRestoreDialog } from '~/components/dialogs/document-restore-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
@@ -58,18 +60,20 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isRestoreDialogOpen, setRestoreDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const isOwner = row.user.id === user.id;
// const isRecipient = !!recipient;
const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status);
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isCurrentTeamDocument = team && row.team?.url === team.url;
const isDocumentDeleted = row.deletedAt !== null;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url);
@@ -171,10 +175,17 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
Void
</DropdownMenuItem> */}
{isDocumentDeleted || (isRecipient && !canManageDocument) ? (
<DropdownMenuItem disabled={isRecipient} onClick={() => setRestoreDialogOpen(true)}>
<RotateCcw className="mr-2 h-4 w-4" />
<Trans>Restore</Trans>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem>
)}
<DropdownMenuLabel>
<Trans>Share</Trans>
@@ -220,6 +231,15 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
canManageDocument={canManageDocument}
/>
<DocumentRestoreDialog
id={row.id}
open={isRestoreDialogOpen}
onOpenChange={setRestoreDialogOpen}
documentTitle={row.title}
teamId={team?.id}
canManageDocument={canManageDocument}
/>
<DocumentMoveDialog
documentId={row.id}
open={isMoveDialogOpen}

View File

@@ -1,6 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2 } from 'lucide-react';
import { Bird, CheckCircle2, Trash } from 'lucide-react';
import { match } from 'ts-pattern';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -30,6 +30,11 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
icon: Bird,
}))
.with(ExtendedDocumentStatus.DELETED, () => ({
title: msg`Nothing in the trash`,
message: msg`There are no documents in the trash.`,
icon: Trash,
}))
.otherwise(() => ({
title: msg`Nothing to do`,
message: msg`All documents have been processed. Any new documents that are sent or received will show here.`,

View File

@@ -9,7 +9,6 @@ import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
@@ -76,8 +75,7 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
},
{
header: _(msg`Actions`),
cell: ({ row }) =>
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
cell: ({ row }) => (
<div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} />

View File

@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { Link, useSearchParams } from 'react-router';
import { z } from 'zod';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
@@ -51,6 +50,7 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.DELETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
@@ -114,13 +114,17 @@ export default function DocumentsPage() {
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<Tabs
value={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
className="overflow-x-auto"
>
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.DELETED,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger

View File

@@ -6,7 +6,6 @@ import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
import {
RECIPIENT_ROLES_DESCRIPTION,
@@ -60,8 +59,6 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/');
}
const isPlatformDocument = await isDocumentPlatform(document);
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const auditLogs = await getDocumentCertificateAuditLogs({
@@ -73,7 +70,6 @@ export async function loader({ request }: Route.LoaderArgs) {
return {
document,
documentLanguage,
isPlatformDocument,
auditLogs,
messages,
};
@@ -89,7 +85,7 @@ export async function loader({ request }: Route.LoaderArgs) {
* Update: Maybe <Trans> tags work now after RR7 migration.
*/
export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
const { document, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData;
const { document, documentLanguage, auditLogs, messages } = loaderData;
const { i18n, _ } = useLingui();
@@ -341,7 +337,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</CardContent>
</Card>
{isPlatformDocument && (
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
@@ -351,7 +346,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
<BrandingLogo className="max-h-6 print:max-h-4" />
</div>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
@@ -13,7 +13,6 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
@@ -73,24 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
? await getRecipientsForAssistant({
token,
})
: [recipient];
if (
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
recipient.role !== RecipientRole.ASSISTANT
) {
const nextPendingRecipient = await getNextPendingRecipient({
documentId: document.id,
currentRecipientId: recipient.id,
});
if (nextPendingRecipient) {
allRecipients.push({
...nextPendingRecipient,
fields: [],
});
}
}
: [];
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,

View File

@@ -99,6 +99,5 @@
"vite": "^6.1.0",
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.10.0-rc.1"
}
}

View File

@@ -8,7 +8,6 @@ command -v docker >/dev/null 2>&1 || {
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get Git information
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
@@ -16,39 +15,12 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker build -f "$SCRIPT_DIR/Dockerfile" \
--progress=plain \
-t "documenso-base" \
-t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT"
# Handle repository tagging
if [ ! -z "$DOCKER_REPOSITORY" ]; then
echo "Using custom repository: $DOCKER_REPOSITORY"
# Add tags for custom repository
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
# Add version tag if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
fi
else
echo "Using default repositories: dockerhub and ghcr.io"
# Add tags for both default repositories
docker tag "documenso-base" "documenso/documenso:latest"
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
# Add version tags if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
fi
fi
# Remove the temporary base tag
docker rmi "documenso-base"

View File

@@ -9,11 +9,11 @@ SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get the platform from environment variable or set to linux/amd64 if not set
# quote the string to prevent word splitting
if [ -z "$PLATFORM" ]; then
PLATFORM="linux/amd64"
fi
# Get Git information
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
@@ -21,41 +21,14 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker buildx build \
-f "$SCRIPT_DIR/Dockerfile" \
--platform=$PLATFORM \
--progress=plain \
-t "documenso-base" \
-t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT"
# Handle repository tagging
if [ ! -z "$DOCKER_REPOSITORY" ]; then
echo "Using custom repository: $DOCKER_REPOSITORY"
# Add tags for custom repository
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
# Add version tag if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
fi
else
echo "Using default repositories: dockerhub and ghcr.io"
# Add tags for both default repositories
docker tag "documenso-base" "documenso/documenso:latest"
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
# Add version tags if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
fi
fi
# Remove the temporary base tag
docker rmi "documenso-base"

5
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.10.0-rc.1",
"version": "1.9.0-rc.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.10.0-rc.1",
"version": "1.9.0-rc.11",
"workspaces": [
"apps/*",
"packages/*"
@@ -95,7 +95,6 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.10.0-rc.1",
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",

View File

@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.10.0-rc.1",
"version": "1.9.0-rc.11",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@@ -14,7 +14,7 @@
"prepare": "husky && husky install || true",
"commitlint": "commitlint --edit",
"clean": "turbo run clean && rimraf node_modules",
"d": "npm run dx && npm run translate:compile && npm run dev",
"d": "npm run dx && npm run dev",
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
"dx:up": "docker compose -f docker/development/compose.yml up -d",
"dx:down": "docker compose -f docker/development/compose.yml down",

View File

@@ -323,7 +323,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
dateFormat: dateFormat?.value,
redirectUrl: body.meta.redirectUrl,
signingOrder: body.meta.signingOrder,
allowDictateNextSigner: body.meta.allowDictateNextSigner,
language: body.meta.language,
typedSignatureEnabled: body.meta.typedSignatureEnabled,
distributionMethod: body.meta.distributionMethod,

View File

@@ -155,7 +155,6 @@ export const ZCreateDocumentMutationSchema = z.object({
}),
redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
typedSignatureEnabled: z.boolean().optional().default(true),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
@@ -219,7 +218,6 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(),
redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
})
.partial()
@@ -287,7 +285,6 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(),
redirectUrl: ZUrlSchema,
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean(),
language: z.enum(SUPPORTED_LANGUAGE_CODES),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
typedSignatureEnabled: z.boolean(),

View File

@@ -210,7 +210,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
}),
},
],
fields: [FieldType.DATE, FieldType.SIGNATURE],
fields: [FieldType.DATE],
});
for (const recipient of recipients) {
@@ -307,7 +307,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
}),
},
],
fields: [FieldType.DATE, FieldType.SIGNATURE],
fields: [FieldType.DATE],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,

View File

@@ -1,390 +0,0 @@
import { expect, test } from '@playwright/test';
import {
DocumentSigningOrder,
DocumentStatus,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { signSignaturePad } from '../fixtures/signature';
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({
page,
}) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const thirdSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner, thirdSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 3 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const firstRecipient = recipients[0];
const { token, fields } = firstRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing and update next recipient
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient info is shown
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
// Update next recipient
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
await page.waitForTimeout(1000);
// Use dialog context to ensure we're targeting the correct form fields
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Name').fill('New Recipient');
await dialog.getByLabel('Email').fill('new.recipient@example.com');
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// First recipient should be completed
const updatedFirstRecipient = updatedDocument.recipients[0];
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
// Second recipient should be the new recipient
const updatedSecondRecipient = updatedDocument.recipients[1];
expect(updatedSecondRecipient.name).toBe('New Recipient');
expect(updatedSecondRecipient.email).toBe('new.recipient@example.com');
expect(updatedSecondRecipient.signingOrder).toBe(2);
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
});
test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', async ({ page }) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const firstRecipient = recipients[0];
const { token, fields } = firstRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient UI is not shown
await expect(
page.getByText('The next recipient to sign this document will be'),
).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// First recipient should be completed
const updatedFirstRecipient = updatedDocument.recipients[0];
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
// Second recipient should remain unchanged
const updatedSecondRecipient = updatedDocument.recipients[1];
expect(updatedSecondRecipient.email).toBe(secondSigner.email);
expect(updatedSecondRecipient.signingOrder).toBe(2);
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
});
test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async ({ page }) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.PARALLEL,
},
update: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.PARALLEL,
},
},
},
},
});
// Test both recipients can sign in parallel
for (const recipient of recipients) {
const { token, fields } = recipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient UI is not shown in parallel flow
await expect(
page.getByText('The next recipient to sign this document will be'),
).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
}
// Verify final document and recipient states
await expect(async () => {
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should be completed since all recipients have signed
expect(updatedDocument.status).toBe(DocumentStatus.COMPLETED);
// All recipients should be completed
for (const recipient of updatedDocument.recipients) {
expect(recipient.signingStatus).toBe(SigningStatus.SIGNED);
}
}).toPass();
});
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({
page,
}) => {
const user = await seedUser();
const assistant = await seedUser();
const signer = await seedUser();
const thirdSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [assistant, signer, thirdSigner],
recipientsCreateOptions: [
{ signingOrder: 1, role: RecipientRole.ASSISTANT },
{ signingOrder: 2, role: RecipientRole.SIGNER },
{ signingOrder: 3, role: RecipientRole.SIGNER },
],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const assistantRecipient = recipients[0];
const { token, fields } = assistantRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible();
await page.getByRole('radio', { name: assistantRecipient.name }).click();
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.SIGNATURE) {
await signSignaturePad(page);
await page.getByRole('button', { name: 'Sign', exact: true }).click();
}
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete assisting and update next recipient
await page.getByRole('button', { name: 'Continue' }).click();
// Verify next recipient info is shown
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
// Update next recipient
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
// Use dialog context to ensure we're targeting the correct form fields
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Name').fill('New Signer');
await dialog.getByLabel('Email').fill('new.signer@example.com');
// Submit and verify completion
await page.getByRole('button', { name: /Continue|Proceed/i }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
await expect(async () => {
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// Assistant should be completed
const updatedAssistant = updatedDocument.recipients[0];
expect(updatedAssistant.signingStatus).toBe(SigningStatus.SIGNED);
expect(updatedAssistant.role).toBe(RecipientRole.ASSISTANT);
// Second recipient should be the new signer
const updatedSigner = updatedDocument.recipients[1];
expect(updatedSigner.name).toBe('New Signer');
expect(updatedSigner.email).toBe('new.signer@example.com');
expect(updatedSigner.signingOrder).toBe(2);
expect(updatedSigner.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(updatedSigner.role).toBe(RecipientRole.SIGNER);
// Third recipient should remain unchanged
const thirdRecipient = updatedDocument.recipients[2];
expect(thirdRecipient.email).toBe(thirdSigner.email);
expect(thirdRecipient.signingOrder).toBe(3);
expect(thirdRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
}).toPass();
});

View File

@@ -56,7 +56,6 @@ test('[PUBLIC_PROFILE]: create profile', async ({ page }) => {
// Go back to public profile page.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`);
await page.getByRole('switch').click();
await page.waitForTimeout(1000);
// Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
@@ -128,7 +127,6 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
// Go back to public profile page.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
await page.getByRole('switch').click();
await page.waitForTimeout(1000);
// Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);

View File

@@ -15,6 +15,7 @@ export const getDocumentStats = async () => {
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
[ExtendedDocumentStatus.DELETED]: 0,
};
counts.forEach((stat) => {

View File

@@ -24,7 +24,6 @@ export type CreateDocumentMetaOptions = {
redirectUrl?: string;
emailSettings?: TDocumentEmailSettings;
signingOrder?: DocumentSigningOrder;
allowDictateNextSigner?: boolean;
distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
@@ -42,7 +41,6 @@ export const upsertDocumentMeta = async ({
password,
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailSettings,
distributionMethod,
typedSignatureEnabled,
@@ -95,7 +93,6 @@ export const upsertDocumentMeta = async ({
documentId,
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailSettings,
distributionMethod,
typedSignatureEnabled,
@@ -109,7 +106,6 @@ export const upsertDocumentMeta = async ({
timezone,
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailSettings,
distributionMethod,
typedSignatureEnabled,

View File

@@ -7,10 +7,7 @@ import {
WebhookTriggerEvents,
} from '@prisma/client';
import {
DOCUMENT_AUDIT_LOG_TYPE,
RECIPIENT_DIFF_TYPE,
} from '@documenso/lib/types/document-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -33,10 +30,6 @@ export type CompleteDocumentWithTokenOptions = {
userId?: number;
authOptions?: TRecipientActionAuth;
requestMetadata?: RequestMetadata;
nextSigner?: {
email: string;
name: string;
};
};
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
@@ -64,7 +57,6 @@ export const completeDocumentWithToken = async ({
token,
documentId,
requestMetadata,
nextSigner,
}: CompleteDocumentWithTokenOptions) => {
const document = await getDocument({ token, documentId });
@@ -154,6 +146,7 @@ export const completeDocumentWithToken = async ({
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
// actionAuth: derivedRecipientActionAuth || undefined,
},
}),
});
@@ -171,9 +164,6 @@ export const completeDocumentWithToken = async ({
select: {
id: true,
signingOrder: true,
name: true,
email: true,
role: true,
},
where: {
documentId: document.id,
@@ -196,49 +186,9 @@ export const completeDocumentWithToken = async ({
const [nextRecipient] = pendingRecipients;
await prisma.$transaction(async (tx) => {
if (nextSigner && document.documentMeta?.allowDictateNextSigner) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: document.id,
user: {
name: recipient.name,
email: recipient.email,
},
requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
});
}
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
...(nextSigner && document.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
data: { sendStatus: SendStatus.SENT },
});
await jobs.triggerJob({

View File

@@ -136,18 +136,26 @@ export const findDocuments = async ({
};
}
const deletedDateRange =
status === ExtendedDocumentStatus.DELETED
? {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
lte: DateTime.now().toJSDate(),
}
: null;
let deletedFilter: Prisma.DocumentWhereInput = {
AND: {
OR: [
{
userId: user.id,
deletedAt: null,
deletedAt: deletedDateRange,
},
{
recipients: {
some: {
email: user.email,
documentDeletedAt: null,
documentDeletedAt: deletedDateRange,
},
},
},
@@ -162,19 +170,19 @@ export const findDocuments = async ({
? [
{
teamId: team.id,
deletedAt: null,
deletedAt: deletedDateRange,
},
{
user: {
email: team.teamEmail.email,
},
deletedAt: null,
deletedAt: deletedDateRange,
},
{
recipients: {
some: {
email: team.teamEmail.email,
documentDeletedAt: null,
documentDeletedAt: deletedDateRange,
},
},
},
@@ -182,7 +190,7 @@ export const findDocuments = async ({
: [
{
teamId: team.id,
deletedAt: null,
deletedAt: deletedDateRange,
},
],
},
@@ -297,6 +305,14 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
},
},
},
{
status: ExtendedDocumentStatus.REJECTED,
recipients: {
some: {
email: user.email,
},
},
},
],
}))
.with(ExtendedDocumentStatus.INBOX, () => ({
@@ -368,7 +384,24 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.REJECTED,
},
},
},
],
}))
.with(ExtendedDocumentStatus.DELETED, () => ({
OR: [
{
userId: user.id,
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
{
recipients: {
some: {
email: user.email,
},
},
},
@@ -410,7 +443,7 @@ const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus,
team: Team & { teamEmail: TeamEmail | null },
visibilityFilters: Prisma.DocumentWhereInput[],
) => {
): Prisma.DocumentWhereInput | null => {
const teamEmail = team.teamEmail?.email ?? null;
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status)
@@ -599,5 +632,32 @@ const findTeamDocumentsFilter = (
return filter;
})
.with(ExtendedDocumentStatus.DELETED, () => {
return {
OR: teamEmail
? [
{
teamId: team.id,
},
{
user: {
email: teamEmail,
},
},
{
recipients: {
some: {
email: teamEmail,
},
},
},
]
: [
{
teamId: team.id,
},
],
};
})
.exhaustive();
};

View File

@@ -1,7 +1,5 @@
import { TeamMemberRole } from '@prisma/client';
import type { Prisma, User } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@prisma/client';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@@ -17,7 +15,7 @@ export type GetStatsInput = {
search?: string;
};
export const getStats = async ({ user, period, search = '', ...options }: GetStatsInput) => {
export const getStats = async ({ user, period, search, ...options }: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
@@ -30,7 +28,7 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
};
}
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
const [ownerCounts, notSignedCounts, hasSignedCounts, deletedCounts] = await (options.team
? getTeamCounts({
...options.team,
createdAt,
@@ -45,6 +43,7 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.DELETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
@@ -71,6 +70,8 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
}
});
stats[ExtendedDocumentStatus.DELETED] = deletedCounts || 0;
Object.keys(stats).forEach((key) => {
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
stats[ExtendedDocumentStatus.ALL] += stats[key];
@@ -167,6 +168,32 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
AND: [searchFilter],
},
}),
// Deleted count
prisma.document.count({
where: {
OR: [
{
userId: user.id,
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
{
recipients: {
some: {
email: user.email,
documentDeletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
},
},
],
AND: [searchFilter],
},
}),
]);
};
@@ -336,5 +363,40 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
}),
notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [],
hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [],
prisma.document.count({
where: {
OR: [
{
teamId,
userId: userIdWhereClause,
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
{
user: {
email: teamEmail,
},
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
{
recipients: {
some: {
email: teamEmail,
documentDeletedAt: {
gte: DateTime.now().minus({ days: 30 }).toJSDate(),
not: null,
},
},
},
},
],
AND: [searchFilter],
},
}),
]);
};

View File

@@ -0,0 +1,108 @@
import { WebhookTriggerEvents } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '@documenso/lib/types/webhook-payload';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
export type RestoreDocumentOptions = {
id: number;
userId: number;
teamId?: number;
requestMetadata: ApiRequestMetadata;
};
export const restoreDocument = async ({
id,
userId,
teamId,
requestMetadata,
}: RestoreDocumentOptions) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const document = await prisma.document.findUnique({
where: {
id,
},
include: {
recipients: true,
documentMeta: true,
team: {
include: {
members: true,
},
},
},
});
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isUserOwner = document.userId === userId;
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
if (!isUserOwner && !isUserTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed to restore this document',
});
}
const restoredDocument = await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: document.id,
type: 'DOCUMENT_RESTORED',
metadata: requestMetadata,
data: {},
}),
});
return await tx.document.update({
where: {
id: document.id,
},
data: {
deletedAt: null,
},
});
});
await prisma.recipient.updateMany({
where: {
documentId: document.id,
documentDeletedAt: {
not: null,
},
},
data: {
documentDeletedAt: null,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_RESTORED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
userId,
teamId,
});
return restoredDocument;
};

View File

@@ -1,37 +0,0 @@
import { prisma } from '@documenso/prisma';
export const getNextPendingRecipient = async ({
documentId,
currentRecipientId,
}: {
documentId: number;
currentRecipientId: number;
}) => {
const recipients = await prisma.recipient.findMany({
where: {
documentId,
},
orderBy: [
{
signingOrder: {
sort: 'asc',
nulls: 'last',
},
},
{
id: 'asc',
},
],
});
const currentIndex = recipients.findIndex((r) => r.id === currentRecipientId);
if (currentIndex === -1 || currentIndex === recipients.length - 1) {
return null;
}
return {
...recipients[currentIndex + 1],
token: '',
};
};

View File

@@ -83,7 +83,6 @@ export type CreateDocumentFromTemplateOptions = {
language?: SupportedLanguageCodes;
distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean;
allowDictateNextSigner?: boolean;
emailSettings?: TDocumentEmailSettings;
};
requestMetadata: ApiRequestMetadata;
@@ -405,10 +404,6 @@ export const createDocumentFromTemplate = async ({
template.team?.teamGlobalSettings?.documentLanguage,
typedSignatureEnabled:
override?.typedSignatureEnabled ?? template.templateMeta?.typedSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ??
template.templateMeta?.allowDictateNextSigner ??
false,
},
},
recipients: {

View File

@@ -39,6 +39,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
'DOCUMENT_RESTORED', // When a deleted document is restored.
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@@ -551,6 +552,14 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
}),
});
/**
* Event: Document restored.
*/
export const ZDocumentAuditLogEventDocumentRestoredSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED),
data: z.object({}),
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
@@ -588,6 +597,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventDocumentRestoredSchema,
]),
);

View File

@@ -51,7 +51,6 @@ export const ZDocumentSchema = DocumentSchema.pick({
documentId: true,
redirectUrl: true,
typedSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
}).nullable(),

View File

@@ -45,7 +45,6 @@ export const ZTemplateSchema = TemplateSchema.pick({
dateFormat: true,
signingOrder: true,
typedSignatureEnabled: true,
allowDictateNextSigner: true,
distributionMethod: true,
templateId: true,
redirectUrl: true,

View File

@@ -46,7 +46,6 @@ export const ZWebhookDocumentMetaSchema = z.object({
dateFormat: z.string(),
redirectUrl: z.string().nullable(),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean(),
typedSignatureEnabled: z.boolean(),
language: z.string(),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_RESTORED';

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "allowDictateNextSigner" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "TemplateMeta" ADD COLUMN "allowDictateNextSigner" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -178,6 +178,7 @@ enum WebhookTriggerEvents {
DOCUMENT_COMPLETED
DOCUMENT_REJECTED
DOCUMENT_CANCELLED
DOCUMENT_RESTORED
}
model Webhook {
@@ -400,7 +401,6 @@ model DocumentMeta {
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
signingOrder DocumentSigningOrder @default(PARALLEL)
allowDictateNextSigner Boolean @default(false)
typedSignatureEnabled Boolean @default(true)
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
@@ -668,7 +668,6 @@ model TemplateMeta {
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
signingOrder DocumentSigningOrder? @default(PARALLEL)
allowDictateNextSigner Boolean @default(false)
typedSignatureEnabled Boolean @default(true)
distributionMethod DocumentDistributionMethod @default(EMAIL)

View File

@@ -4,6 +4,7 @@ export const ExtendedDocumentStatus = {
...DocumentStatus,
INBOX: 'INBOX',
ALL: 'ALL',
DELETED: 'DELETED',
} as const;
export type ExtendedDocumentStatus =

View File

@@ -21,6 +21,7 @@ import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stat
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { moveDocumentToTeam } from '@documenso/lib/server-only/document/move-document-to-team';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { restoreDocument } from '@documenso/lib/server-only/document/restore-document';
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
@@ -53,6 +54,7 @@ import {
ZMoveDocumentToTeamResponseSchema,
ZMoveDocumentToTeamSchema,
ZResendDocumentMutationSchema,
ZRestoreDocumentMutationSchema,
ZSearchDocumentsMutationSchema,
ZSetSigningOrderForDocumentMutationSchema,
ZSuccessResponseSchema,
@@ -371,7 +373,6 @@ export const documentRouter = router({
redirectUrl: meta.redirectUrl,
distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder,
allowDictateNextSigner: meta.allowDictateNextSigner,
emailSettings: meta.emailSettings,
requestMetadata: ctx.metadata,
});
@@ -416,6 +417,36 @@ export const documentRouter = router({
return ZGenericSuccessResponse;
}),
/**
* @public
*/
restoreDocument: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/restore',
summary: 'Restore deleted document',
tags: ['Document'],
},
})
.input(ZRestoreDocumentMutationSchema)
.output(ZSuccessResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId } = input;
const userId = ctx.user.id;
await restoreDocument({
id: documentId,
userId,
teamId,
requestMetadata: ctx.metadata,
});
return ZGenericSuccessResponse;
}),
/**
* @public
*/

View File

@@ -145,6 +145,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
[ExtendedDocumentStatus.PENDING]: z.number(),
[ExtendedDocumentStatus.COMPLETED]: z.number(),
[ExtendedDocumentStatus.REJECTED]: z.number(),
[ExtendedDocumentStatus.DELETED]: z.number(),
[ExtendedDocumentStatus.INBOX]: z.number(),
[ExtendedDocumentStatus.ALL]: z.number(),
}),
@@ -268,7 +269,6 @@ export const ZUpdateDocumentRequestSchema = z.object({
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
@@ -349,6 +349,12 @@ export const ZDeleteDocumentMutationSchema = z.object({
export type TDeleteDocumentMutationSchema = z.infer<typeof ZDeleteDocumentMutationSchema>;
export const ZRestoreDocumentMutationSchema = z.object({
documentId: z.number(),
});
export type TRestoreDocumentMutationSchema = z.infer<typeof ZRestoreDocumentMutationSchema>;
export const ZSearchDocumentsMutationSchema = z.object({
query: z.string(),
});

View File

@@ -436,13 +436,12 @@ export const recipientRouter = router({
completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
const { token, documentId, authOptions, nextSigner } = input;
const { token, documentId, authOptions } = input;
return await completeDocumentWithToken({
token,
documentId,
authOptions,
nextSigner,
userId: ctx.user?.id,
requestMetadata: ctx.metadata.requestMetadata,
});

View File

@@ -212,12 +212,6 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
token: z.string(),
documentId: z.number(),
authOptions: ZRecipientActionAuthSchema.optional(),
nextSigner: z
.object({
email: z.string().email(),
name: z.string().min(1),
})
.optional(),
});
export type TCompleteDocumentWithTokenMutationSchema = z.infer<

View File

@@ -165,7 +165,6 @@ export const ZUpdateTemplateRequestSchema = z.object({
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
})
.optional(),
});

View File

@@ -9,7 +9,7 @@ import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react';
import { GripVerticalIcon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
@@ -29,7 +29,6 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types';
import { ZAddSignersFormSchema } from './add-signers.types';
@@ -49,7 +48,6 @@ export type AddSignersFormProps = {
recipients: Recipient[];
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean;
@@ -60,7 +58,6 @@ export const AddSignersFormPartial = ({
recipients,
fields,
signingOrder,
allowDictateNextSigner,
isDocumentEnterprise,
onSubmit,
isDocumentPdfLoaded,
@@ -107,7 +104,6 @@ export const AddSignersFormPartial = ({
)
: defaultRecipients,
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
},
});
@@ -358,7 +354,6 @@ export const AddSignersFormPartial = ({
form.setValue('signers', updatedSigners);
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
form.setValue('allowDictateNextSigner', false);
}, [form]);
return (
@@ -394,11 +389,6 @@ export const AddSignersFormPartial = ({
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
);
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false);
}
}}
disabled={isSubmitting || hasDocumentBeenSent}
/>
@@ -413,50 +403,6 @@ export const AddSignersFormPartial = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={field.onChange}
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
/>
</FormControl>
<div className="flex items-center">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the sequence
instead of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
<DragDropContext
onDragEnd={onDragEnd}
sensors={[

View File

@@ -25,7 +25,6 @@ export const ZAddSignersFormSchema = z
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
})
.refine(
(schema) => {

View File

@@ -9,7 +9,7 @@ import { Trans } from '@lingui/react/macro';
import type { TemplateDirectLink } from '@prisma/client';
import { DocumentSigningOrder, type Field, type Recipient, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
import { GripVerticalIcon, Link2Icon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useSession } from '@documenso/lib/client-only/providers/session';
@@ -47,11 +47,10 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
recipients: Recipient[];
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
templateDirectLink?: TemplateDirectLink | null;
templateDirectLink: TemplateDirectLink | null;
isEnterprise: boolean;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
isDocumentPdfLoaded: boolean;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
};
export const AddTemplatePlaceholderRecipientsFormPartial = ({
@@ -61,7 +60,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
templateDirectLink,
fields,
signingOrder,
allowDictateNextSigner,
isDocumentPdfLoaded,
onSubmit,
}: AddTemplatePlaceholderRecipientsFormProps) => {
@@ -114,7 +112,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
defaultValues: {
signers: generateDefaultFormSigners(),
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
},
});
@@ -122,7 +119,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
form.reset({
signers: generateDefaultFormSigners(),
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -381,7 +377,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
form.setValue('signers', updatedSigners);
form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
form.setValue('allowDictateNextSigner', false);
}, [form]);
return (
@@ -421,11 +416,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
);
// If sequential signing is turned off, disable dictate next signer
if (!checked) {
form.setValue('allowDictateNextSigner', false);
}
}}
disabled={isSubmitting}
/>
@@ -441,49 +431,6 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
)}
/>
<FormField
control={form.control}
name="allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={field.onChange}
disabled={isSubmitting || !isSigningOrderSequential}
/>
</FormControl>
<div className="flex items-center">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the sequence
instead of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
{/* Drag and drop context */}
<DragDropContext
onDragEnd={onDragEnd}

View File

@@ -21,7 +21,6 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
})
.refine(
(schema) => {