Compare commits

..

5 Commits

28 changed files with 725 additions and 390 deletions

View File

@ -14,4 +14,4 @@
"public-api": "Public API",
"embedding": "Embedding",
"webhooks": "Webhooks"
}
}

View File

@ -85,13 +85,12 @@ You can also set the recipient's role, which determines their actions and permis
Documenso has 4 roles for recipients with different permissions and actions.
| Role | Function | Action required | Signature |
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No |
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
| Role | Function | Action required | Signature |
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No |
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
### Fields

View File

@ -16,13 +16,9 @@ import { Input } from '@documenso/ui/primitives/input';
export type SigningVolume = {
id: number;
name: string;
email: string;
signingVolume: number;
createdAt: Date;
planId: string;
userId?: number | null;
teamId?: number | null;
isTeam: boolean;
};
type LeaderboardTableProps = {

View File

@ -4,7 +4,7 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { LeaderboardTable, type SigningVolume } from './data-table-leaderboard';
import { LeaderboardTable } from './data-table-leaderboard';
import { search } from './fetch-leaderboard.actions';
type AdminLeaderboardProps = {
@ -32,7 +32,7 @@ export default async function Leaderboard({ searchParams = {} }: AdminLeaderboar
const sortBy = searchParams.sortBy || 'signingVolume';
const sortOrder = searchParams.sortOrder || 'desc';
const { leaderboard, totalPages } = await search({
const { leaderboard: signingVolume, totalPages } = await search({
search: searchString,
page,
perPage,
@ -40,22 +40,14 @@ export default async function Leaderboard({ searchParams = {} }: AdminLeaderboar
sortOrder,
});
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
...item,
name: item.name || '',
createdAt: item.createdAt || new Date(),
}));
return (
<div>
<div className="flex items-center">
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
</div>
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
<div className="mt-8">
<LeaderboardTable
signingVolume={typedSigningVolume}
signingVolume={signingVolume}
totalPages={totalPages}
page={page}
perPage={perPage}

View File

@ -73,7 +73,7 @@ export const EditDocumentForm = ({
const { recipients, fields } = document;
const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
@ -85,19 +85,6 @@ export const EditDocumentForm = ({
},
});
const { mutateAsync: setSigningOrderForDocument } =
trpc.document.setSigningOrderForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData, id: Number(newData.id) }),
);
},
});
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: ({ fields: newFields }) => {
@ -216,9 +203,12 @@ export const EditDocumentForm = ({
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try {
await Promise.all([
setSigningOrderForDocument({
updateDocument({
documentId: document.id,
signingOrder: data.signingOrder,
meta: {
signingOrder: data.signingOrder,
modifyNextSigner: data.modifyNextSigner,
},
}),
setRecipients({
@ -391,6 +381,7 @@ export const EditDocumentForm = ({
documentFlow={documentFlow.signers}
recipients={recipients}
signingOrder={document.documentMeta?.signingOrder}
modifyNextSigner={document.documentMeta?.modifyNextSigner}
fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit}

View File

@ -76,6 +76,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
? {
...templateMeta,
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
modifyNextSigner: templateMeta.modifyNextSigner ?? false,
documentId: 0,
}
: undefined;

View File

@ -15,7 +15,12 @@ import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Recipient } from '@documenso/prisma/client';
import { type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
import {
DocumentSigningOrder,
type Field,
FieldType,
RecipientRole,
} from '@documenso/prisma/client';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
@ -40,6 +45,12 @@ export type SigningFormProps = {
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void;
isLastRecipient: boolean;
};
type SigningFormData = {
email?: string;
name?: string;
};
export const SigningForm = ({
@ -50,6 +61,7 @@ export const SigningForm = ({
isRecipientsTurn,
allRecipients = [],
setSelectedSignerId,
isLastRecipient,
}: SigningFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -77,7 +89,7 @@ export const SigningForm = ({
},
});
const { handleSubmit, formState } = useForm();
const { handleSubmit, formState } = useForm<SigningFormData>();
// Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
@ -102,20 +114,58 @@ export const SigningForm = ({
validateFieldsInserted(fieldsRequiringValidation);
};
const onFormSubmit = async () => {
setValidateUninsertedFields(true);
const completeDocument = async (
authOptions?: TRecipientActionAuth,
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token,
documentId: document.id,
authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
await completeDocumentWithToken(payload);
if (hasSignatureField && !signatureValid) {
return;
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
};
const onFormSubmit = async (data: SigningFormData) => {
try {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
if (hasSignatureField && !signatureValid) {
throw new Error('Please provide a valid signature');
}
if (!isFieldsValid) {
throw new Error('Please complete all required fields');
}
const nextSigner =
data.email && data.name
? {
email: data.email,
name: data.name,
}
: undefined;
await completeDocument(undefined, nextSigner);
} catch (error) {
toast({
title: 'Error',
description: 'An error occurred while completing the document. Please try again.',
variant: 'destructive',
});
}
if (!isFieldsValid) {
return;
}
await completeDocument();
};
const onAssistantFormSubmit = () => {
@ -143,22 +193,6 @@ export const SigningForm = ({
}
};
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
await completeDocumentWithToken({
token: recipient.token,
documentId: document.id,
authOptions,
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: document.id,
timestamp: new Date().toISOString(),
});
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
};
return (
<div
className={cn(
@ -208,12 +242,21 @@ export const SigningForm = ({
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
onSignatureComplete={async (nextSigner) => {
await handleSubmit(async (formData) =>
onFormSubmit({ ...formData, ...nextSigner }),
)();
}}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
canModifyNextSigner={
document.documentMeta?.modifyNextSigner &&
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
!isLastRecipient
}
/>
</div>
</div>
@ -383,12 +426,21 @@ export const SigningForm = ({
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
onSignatureComplete={async (nextSigner) => {
await handleSubmit(async (formData) =>
onFormSubmit({ ...formData, ...nextSigner }),
)();
}}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
canModifyNextSigner={
document.documentMeta?.modifyNextSigner &&
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
!isLastRecipient
}
/>
</div>
</fieldset>

View File

@ -9,6 +9,7 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
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 { getIsLastRecipient } from '@documenso/lib/server-only/recipient/get-is-last-recipient';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
@ -44,7 +45,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, recipient, fields, completedFields] = await Promise.all([
const [document, recipient, fields, completedFields, isLastRecipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
userId: user?.id,
@ -53,6 +54,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
getRecipientByToken({ token }).catch(() => null),
getFieldsForToken({ token }),
getCompletedFieldsForToken({ token }),
getIsLastRecipient({ token }),
]);
if (
@ -169,6 +171,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
isLastRecipient={isLastRecipient}
/>
</DocumentAuthProvider>
</SigningProvider>

View File

@ -37,11 +37,6 @@ export const RecipientProvider = ({
recipient,
targetSigner = null,
}: RecipientProviderProps) => {
// console.log({
// recipient,
// targetSigner,
// isAssistantMode: !!targetSigner,
// });
return (
<RecipientContext.Provider
value={{

View File

@ -1,18 +1,36 @@
'use client';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/macro';
import { ArrowRight } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import type { Field } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
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 { SigningDisclosure } from '~/components/general/signing-disclosure';
@ -21,12 +39,26 @@ export type SignDialogProps = {
documentTitle: string;
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
onSignatureComplete: (nextSigner?: { email?: string; name?: string }) => void | Promise<void>;
role: RecipientRole;
disabled?: boolean;
canModifyNextSigner?: boolean;
};
export const SignDialog = ({
const formSchema = z.object({
modifyNextSigner: z.boolean().default(false),
nextSigner: z
.object({
email: z.string().email({ message: 'Please enter a valid email address' }).optional(),
name: z.string().optional(),
})
.optional()
.default({}),
});
type TFormSchema = z.infer<typeof formSchema>;
export function SignDialog({
isSubmitting,
documentTitle,
fields,
@ -34,7 +66,9 @@ export const SignDialog = ({
onSignatureComplete,
role,
disabled = false,
}: SignDialogProps) => {
canModifyNextSigner = false,
}: SignDialogProps) {
const [step, setStep] = useState(1);
const [showDialog, setShowDialog] = useState(false);
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
@ -47,104 +81,336 @@ export const SignDialog = ({
setShowDialog(open);
};
const totalSteps = 2;
const handleContinue = () => {
if (step < totalSteps) {
setStep(step + 1);
}
};
const form = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
});
const onFormSubmit = async (data: TFormSchema) => {
try {
await fieldsValidated();
await onSignatureComplete({
email: data.nextSigner.email?.trim().toLowerCase(),
name: data.nextSigner.name?.trim(),
});
setShowDialog(false);
form.reset();
} catch (err) {
console.error(err);
}
};
return (
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
className="w-full"
type="button"
size="lg"
onClick={fieldsValidated}
loading={isSubmitting}
disabled={disabled}
>
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
)}
</div>
<SigningDisclosure className="mt-4" />
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<>
{!canModifyNextSigner ? (
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
className="w-full"
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);
}}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
className="flex-1"
disabled={!isComplete}
size="lg"
onClick={fieldsValidated}
loading={isSubmitting}
onClick={onSignatureComplete}
disabled={disabled}
>
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogTrigger>
<DialogContent>
<DialogTitle>
<div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
)}
</div>
<SigningDisclosure 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);
}}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
className="flex-1"
disabled={!isComplete}
loading={isSubmitting}
onClick={async (e) => {
e.preventDefault();
await onSignatureComplete();
}}
>
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<Dialog
onOpenChange={(open) => {
if (open) setStep(1);
}}
>
<DialogTrigger asChild>
<Button
className="w-full"
type="button"
size="lg"
onClick={fieldsValidated}
loading={isSubmitting}
disabled={disabled}
>
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>
{step === 1 && (
<div className="text-foreground text-base font-semibold">
<Trans>
Modify Next Signer <span className="text-muted-foreground">(Optional)</span>
</Trans>
</div>
)}
{step === 2 && (
<div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
</div>
)}
</DialogTitle>
{step === 1 && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="modifyNextSigner"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="font-normal">
<Trans>Modify next signer details</Trans>
</FormLabel>
</FormItem>
)}
/>
{form.watch('modifyNextSigner') && (
<>
<FormField
control={form.control}
name="nextSigner.email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Next Signer Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nextSigner.name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Next Signer Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
)}
{step === 2 && (
<>
<div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.SIGNER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
)}
{role === RecipientRole.APPROVER && (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>
)}
</div>
<SigningDisclosure className="mt-4" />
</>
)}
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex justify-center space-x-1.5 max-sm:order-1">
{[...Array(totalSteps)].map((_, index) => (
<button
key={index}
onClick={() => setStep(index + 1)}
className={cn(
'bg-primary h-1.5 w-1.5 rounded-full',
index + 1 === step ? 'bg-primary' : 'opacity-20',
)}
type="button"
aria-label={`Go to step ${index + 1}`}
/>
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost">
Cancel
</Button>
</DialogClose>
{step === 1 && (
<Button className="group" type="button" onClick={handleContinue}>
Next
<ArrowRight
className="-me-1 ms-2 opacity-60 transition-transform group-hover:translate-x-0.5"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
</Button>
)}
{step === 2 && (
<Button
type="button"
className="flex-1"
disabled={!isComplete}
loading={isSubmitting}
onClick={form.handleSubmit(onFormSubmit)}
>
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
</Button>
)}
</DialogFooter>
</div>
</DialogContent>
</Dialog>
)}
</>
);
};
}

View File

@ -49,6 +49,7 @@ export type SigningPageViewProps = {
completedFields: CompletedField[];
isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
isLastRecipient: boolean;
};
export const SigningPageView = ({
@ -58,6 +59,7 @@ export const SigningPageView = ({
completedFields,
isRecipientsTurn,
allRecipients = [],
isLastRecipient,
}: SigningPageViewProps) => {
const { documentData, documentMeta } = document;
@ -159,6 +161,7 @@ export const SigningPageView = ({
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
isLastRecipient={isLastRecipient}
setSelectedSignerId={setSelectedSignerId}
/>
</div>

View File

@ -10,7 +10,6 @@ export type EmbedDocumentCompletedPageProps = {
};
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
console.log({ signature });
return (
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold">

View File

@ -1,7 +1,15 @@
import { prisma } from '@documenso/prisma';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
type GetSigningVolumeOptions = {
export type SigningVolume = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
};
export type GetSigningVolumeOptions = {
search?: string;
page?: number;
perPage?: number;
@ -9,187 +17,85 @@ type GetSigningVolumeOptions = {
sortOrder?: 'asc' | 'desc';
};
export const getSigningVolume = async ({
export async function getSigningVolume({
search = '',
page = 1,
perPage = 10,
sortBy = 'signingVolume',
sortOrder = 'desc',
}: GetSigningVolumeOptions) => {
const validPage = Math.max(1, page);
const validPerPage = Math.max(1, perPage);
const skip = (validPage - 1) * validPerPage;
}: GetSigningVolumeOptions) {
const offset = Math.max(page - 1, 0) * perPage;
const activeSubscriptions = await prisma.subscription.findMany({
where: {
status: SubscriptionStatus.ACTIVE,
},
select: {
id: true,
planId: true,
userId: true,
teamId: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
},
team: {
select: {
id: true,
name: true,
teamEmail: {
select: {
email: true,
},
},
createdAt: true,
},
},
},
});
let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
.leftJoin('Document as ud', (join) =>
join
.onRef('u.id', '=', 'ud.userId')
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('ud.deletedAt', 'is', null)
.on('ud.teamId', 'is', null),
)
.leftJoin('Document as td', (join) =>
join
.onRef('t.id', '=', 'td.teamId')
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('td.deletedAt', 'is', null),
)
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
)
.select([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
const userSubscriptionsMap = new Map();
const teamSubscriptionsMap = new Map();
switch (sortBy) {
case 'name':
findQuery = findQuery.orderBy('name', sortOrder);
break;
case 'createdAt':
findQuery = findQuery.orderBy('createdAt', sortOrder);
break;
case 'signingVolume':
findQuery = findQuery.orderBy('signingVolume', sortOrder);
break;
default:
findQuery = findQuery.orderBy('signingVolume', 'desc');
}
activeSubscriptions.forEach((subscription) => {
const isTeam = !!subscription.teamId;
findQuery = findQuery.limit(perPage).offset(offset);
if (isTeam && subscription.teamId) {
if (!teamSubscriptionsMap.has(subscription.teamId)) {
teamSubscriptionsMap.set(subscription.teamId, {
id: subscription.id,
planId: subscription.planId,
teamId: subscription.teamId,
name: subscription.team?.name || '',
email: subscription.team?.teamEmail?.email || `Team ${subscription.team?.id}`,
createdAt: subscription.team?.createdAt,
isTeam: true,
subscriptionIds: [subscription.id],
});
} else {
const existingTeam = teamSubscriptionsMap.get(subscription.teamId);
existingTeam.subscriptionIds.push(subscription.id);
}
} else if (subscription.userId) {
if (!userSubscriptionsMap.has(subscription.userId)) {
userSubscriptionsMap.set(subscription.userId, {
id: subscription.id,
planId: subscription.planId,
userId: subscription.userId,
name: subscription.user?.name || '',
email: subscription.user?.email || '',
createdAt: subscription.user?.createdAt,
isTeam: false,
subscriptionIds: [subscription.id],
});
} else {
const existingUser = userSubscriptionsMap.get(subscription.userId);
existingUser.subscriptionIds.push(subscription.id);
}
}
});
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
const subscriptions = [
...Array.from(userSubscriptionsMap.values()),
...Array.from(teamSubscriptionsMap.values()),
];
const filteredSubscriptions = search
? subscriptions.filter((sub) => {
const searchLower = search.toLowerCase();
return (
sub.name?.toLowerCase().includes(searchLower) ||
sub.email?.toLowerCase().includes(searchLower)
);
})
: subscriptions;
const signingVolume = await Promise.all(
filteredSubscriptions.map(async (subscription) => {
let signingVolume = 0;
if (subscription.userId && !subscription.isTeam) {
const personalCount = await prisma.document.count({
where: {
userId: subscription.userId,
status: DocumentStatus.COMPLETED,
teamId: null,
},
});
signingVolume += personalCount;
const userTeams = await prisma.teamMember.findMany({
where: {
userId: subscription.userId,
},
select: {
teamId: true,
},
});
if (userTeams.length > 0) {
const teamIds = userTeams.map((team) => team.teamId);
const teamCount = await prisma.document.count({
where: {
teamId: {
in: teamIds,
},
status: DocumentStatus.COMPLETED,
},
});
signingVolume += teamCount;
}
}
if (subscription.teamId) {
const teamCount = await prisma.document.count({
where: {
teamId: subscription.teamId,
status: DocumentStatus.COMPLETED,
},
});
signingVolume += teamCount;
}
return {
...subscription,
signingVolume,
};
}),
);
const sortedResults = [...signingVolume].sort((a, b) => {
if (sortBy === 'name') {
return sortOrder === 'asc'
? (a.name || '').localeCompare(b.name || '')
: (b.name || '').localeCompare(a.name || '');
}
if (sortBy === 'createdAt') {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
}
return sortOrder === 'asc'
? a.signingVolume - b.signingVolume
: b.signingVolume - a.signingVolume;
});
const paginatedResults = sortedResults.slice(skip, skip + validPerPage);
const totalPages = Math.ceil(sortedResults.length / validPerPage);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
leaderboard: paginatedResults,
totalPages,
leaderboard: results,
totalPages: Math.ceil(Number(count) / perPage),
};
};
}

View File

@ -28,6 +28,7 @@ export type CreateDocumentMetaOptions = {
distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
modifyNextSigner?: boolean;
requestMetadata: ApiRequestMetadata;
};
@ -46,6 +47,7 @@ export const upsertDocumentMeta = async ({
distributionMethod,
typedSignatureEnabled,
language,
modifyNextSigner,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const document = await prisma.document.findFirst({
@ -98,6 +100,7 @@ export const upsertDocumentMeta = async ({
distributionMethod,
typedSignatureEnabled,
language,
modifyNextSigner,
},
update: {
subject,
@ -111,6 +114,7 @@ export const upsertDocumentMeta = async ({
distributionMethod,
typedSignatureEnabled,
language,
modifyNextSigner,
},
});

View File

@ -28,6 +28,10 @@ export type CompleteDocumentWithTokenOptions = {
userId?: number;
authOptions?: TRecipientActionAuth;
requestMetadata?: RequestMetadata;
nextSigner?: {
email: string;
name: string;
};
};
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
@ -51,10 +55,53 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
});
};
export const delegateNextSigner = async ({
documentId,
currentRecipientId,
nextSigner,
}: {
documentId: number;
currentRecipientId: number;
nextSigner: { email: string; name: string };
}) => {
const document = await prisma.document.findUnique({
where: { id: documentId },
include: {
recipients: {
orderBy: [{ signingOrder: 'asc' }, { id: 'asc' }],
},
},
});
if (!document) {
throw new Error('Document not found');
}
const currentRecipient = document.recipients.find((r) => r.id === currentRecipientId);
const nextRecipient = document.recipients.find(
(r) => r.signingOrder === (currentRecipient?.signingOrder ?? 0) + 1,
);
if (!nextRecipient) {
throw new Error('Next recipient not found');
}
await prisma.recipient.update({
where: { id: nextRecipient.id },
data: {
email: nextSigner.email,
name: nextSigner.name,
},
});
return nextRecipient;
};
export const completeDocumentWithToken = async ({
token,
documentId,
requestMetadata,
nextSigner,
}: CompleteDocumentWithTokenOptions) => {
const document = await getDocument({ token, documentId });
@ -112,6 +159,18 @@ export const completeDocumentWithToken = async ({
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// }
if (
nextSigner &&
document.documentMeta?.modifyNextSigner &&
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL
) {
await delegateNextSigner({
documentId: document.id,
currentRecipientId: recipient.id,
nextSigner,
});
}
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {

View File

@ -0,0 +1,46 @@
import { prisma } from '@documenso/prisma';
import { DocumentSigningOrder, RecipientRole, SigningStatus } from '@documenso/prisma/client';
export type GetIsLastRecipientOptions = {
token: string;
};
export async function getIsLastRecipient({ token }: GetIsLastRecipientOptions) {
const document = await prisma.document.findFirstOrThrow({
where: {
recipients: {
some: {
token,
},
},
},
include: {
documentMeta: true,
recipients: {
where: {
role: {
not: RecipientRole.CC,
},
},
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
},
});
if (document.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
const unsignedRecipients = document.recipients.filter(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
);
return unsignedRecipients.length <= 1;
}
const { recipients } = document;
const currentRecipientIndex = recipients.findIndex((r) => r.token === token);
if (currentRecipientIndex === -1) {
return false;
}
return currentRecipientIndex === recipients.length - 1;
}

View File

@ -55,6 +55,7 @@ export const ZDocumentSchema = DocumentSchema.pick({
typedSignatureEnabled: true,
language: true,
emailSettings: true,
modifyNextSigner: true,
}).nullable(),
recipients: ZRecipientLiteSchema.array(),
fields: ZFieldSchema.array(),

View File

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

View File

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

View File

@ -1,18 +0,0 @@
/*
Warnings:
- You are about to drop the column `expires` on the `Session` table. All the data in the column will be lost.
- Added the required column `expiresAt` to the `Session` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "password" TEXT;
-- AlterTable
ALTER TABLE "Session" DROP COLUMN "expires",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "ipAddress" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "userAgent" TEXT;

View File

@ -270,25 +270,18 @@ model Account {
scope String?
id_token String? @db.Text
session_state String?
password String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
id String @id @default(cuid())
sessionToken String @unique
userId Int
ipAddress String?
userAgent String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
expires DateTime
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum DocumentStatus {
@ -397,6 +390,7 @@ model DocumentMeta {
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
signingOrder DocumentSigningOrder @default(PARALLEL)
modifyNextSigner Boolean @default(false)
typedSignatureEnabled Boolean @default(true)
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
@ -666,6 +660,7 @@ model TemplateMeta {
signingOrder DocumentSigningOrder? @default(PARALLEL)
typedSignatureEnabled Boolean @default(true)
distributionMethod DocumentDistributionMethod @default(EMAIL)
modifyNextSigner Boolean @default(false)
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)

View File

@ -266,15 +266,14 @@ export const documentRouter = router({
/**
* @public
*
* Todo: Refactor to updateDocument.
*/
setSettingsForDocument: authenticatedProcedure
updateDocument: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/update',
summary: 'Update document',
description: 'Update an existing document',
tags: ['Document'],
},
})
@ -286,9 +285,9 @@ export const documentRouter = router({
const userId = ctx.user.id;
if (Object.values(meta).length > 0) {
if (Object.keys(meta).length > 0) {
await upsertDocumentMeta({
userId: ctx.user.id,
userId,
teamId,
documentId,
subject: meta.subject,
@ -301,6 +300,7 @@ export const documentRouter = router({
distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder,
emailSettings: meta.emailSettings,
modifyNextSigner: meta.modifyNextSigner,
requestMetadata: ctx.metadata,
});
}

View File

@ -251,6 +251,7 @@ export const ZUpdateDocumentRequestSchema = z.object({
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
modifyNextSigner: z.boolean().optional(),
})
.optional(),
});

View File

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

View File

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

View File

@ -49,6 +49,7 @@ export type AddSignersFormProps = {
recipients: Recipient[];
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
modifyNextSigner?: boolean | null;
isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean;
@ -59,6 +60,7 @@ export const AddSignersFormPartial = ({
recipients,
fields,
signingOrder,
modifyNextSigner,
isDocumentEnterprise,
onSubmit,
isDocumentPdfLoaded,
@ -107,6 +109,7 @@ export const AddSignersFormPartial = ({
)
: defaultRecipients,
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
modifyNextSigner: modifyNextSigner ?? false,
},
});
@ -404,6 +407,35 @@ export const AddSignersFormPartial = ({
</FormItem>
)}
/>
{isSigningOrderSequential && (
<FormField
control={form.control}
name="modifyNextSigner"
render={({ field }) => (
<FormItem className="mb-6 flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
id="modifyNextSigner"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
<FormLabel
htmlFor="modifyNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Modify next signer</Trans>
</FormLabel>
</FormItem>
)}
/>
)}
<DragDropContext
onDragEnd={onDragEnd}
sensors={[

View File

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

View File

@ -183,12 +183,12 @@ const FormMessage = React.forwardRef<
FormMessage.displayName = 'FormMessage';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
};