Compare commits

..

8 Commits

49 changed files with 417 additions and 1179 deletions

View File

@ -158,7 +158,6 @@ export const SinglePlayerClient = () => {
expired: null, expired: null,
signedAt: null, signedAt: null,
readStatus: 'OPENED', readStatus: 'OPENED',
documentDeletedAt: null,
signingStatus: 'NOT_SIGNED', signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT', sendStatus: 'NOT_SENT',
role: 'SIGNER', role: 'SIGNER',

View File

@ -19,7 +19,7 @@ import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client'; import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { import {
@ -41,7 +41,7 @@ export type DocumentPageViewDropdownProps = {
Recipient: Recipient[]; Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null }; team?: Pick<Team, 'id' | 'url'>;
}; };
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
@ -59,10 +59,9 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
const isOwner = document.User.id === session.user.id; const isOwner = document.User.id === session.user.id;
const isDraft = document.status === DocumentStatus.DRAFT; const isDraft = document.status === DocumentStatus.DRAFT;
const isDeleted = document.deletedAt !== null;
const isComplete = document.status === DocumentStatus.COMPLETED; const isComplete = document.status === DocumentStatus.COMPLETED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && document.team?.url === team.url; const isCurrentTeamDocument = team && document.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@ -128,10 +127,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
@ -158,15 +154,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
/> />
</DropdownMenuContent> </DropdownMenuContent>
<DeleteDocumentDialog {isDocumentDeletable && (
id={document.id} <DeleteDocumentDialog
status={document.status} id={document.id}
documentTitle={document.title} status={document.status}
open={isDeleteDialogOpen} documentTitle={document.title}
canManageDocument={canManageDocument} open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
/> />
)}
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DuplicateDocumentDialog <DuplicateDocumentDialog
id={document.id} id={document.id}

View File

@ -12,8 +12,7 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { Team, TeamEmail } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -35,7 +34,7 @@ export type DocumentPageViewProps = {
params: { params: {
id: string; id: string;
}; };
team?: Team & { teamEmail: TeamEmail | null }; team?: Team;
}; };
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => { export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
@ -119,17 +118,11 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip <StackAvatarsWithTooltip recipients={recipients} position="bottom">
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span> <span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div> </div>
)} )}
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
</div> </div>
</div> </div>

View File

@ -224,10 +224,6 @@ export const EditDocumentForm = ({
} }
}; };
const setSubjectFormFields = (subject?: string, message?: string) => {
// Add functionality here
};
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => { const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
try { try {
await addFields({ await addFields({
@ -363,7 +359,6 @@ export const EditDocumentForm = ({
fields={fields} fields={fields}
onSubmit={onAddSubjectFormSubmit} onSubmit={onAddSubjectFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
setSubjectFormFields={setSubjectFormFields}
/> />
</Stepper> </Stepper>
</DocumentFlowFormContainer> </DocumentFlowFormContainer>

View File

@ -92,11 +92,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip <StackAvatarsWithTooltip recipients={recipients} position="bottom">
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span> <span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div> </div>

View File

@ -15,6 +15,7 @@ import {
Pencil, Pencil,
Share, Share,
Trash2, Trash2,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
@ -44,7 +45,7 @@ export type DataTableActionDropdownProps = {
Recipient: Recipient[]; Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string }; team?: Pick<Team, 'id' | 'url'>;
}; };
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => { export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
@ -66,8 +67,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
// const isPending = row.status === DocumentStatus.PENDING; // const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@ -106,14 +107,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger data-testid="document-table-action-btn"> <DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" /> <MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount> <DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel> <DropdownMenuLabel>Action</DropdownMenuLabel>
{!isDraft && recipient && recipient?.role !== RecipientRole.CC && ( {recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild> <DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}> <Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && ( {recipient?.role === RecipientRole.VIEWER && (
@ -140,7 +141,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem disabled={!canManageDocument || isComplete} asChild> <DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}/edit`}> <Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit
@ -157,18 +158,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
{/* No point displaying this if there's no functionality. */} <DropdownMenuItem disabled>
{/* <DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" /> <XCircle className="mr-2 h-4 w-4" />
Void Void
</DropdownMenuItem> */} </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? 'Delete' : 'Hide'} Delete
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel> <DropdownMenuLabel>Share</DropdownMenuLabel>
@ -189,16 +186,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
/> />
</DropdownMenuContent> </DropdownMenuContent>
<DeleteDocumentDialog {isDocumentDeletable && (
id={row.id} <DeleteDocumentDialog
status={row.status} id={row.id}
documentTitle={row.title} status={row.status}
open={isDeleteDialogOpen} documentTitle={row.title}
onOpenChange={setDeleteDialogOpen} open={isDeleteDialogOpen}
teamId={team?.id} onOpenChange={setDeleteDialogOpen}
canManageDocument={canManageDocument} teamId={team?.id}
/> />
)}
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DuplicateDocumentDialog <DuplicateDocumentDialog
id={row.id} id={row.id}

View File

@ -29,7 +29,7 @@ export type DocumentsDataTableProps = {
} }
>; >;
showSenderColumn?: boolean; showSenderColumn?: boolean;
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string }; team?: Pick<Team, 'id' | 'url'>;
}; };
export const DocumentsDataTable = ({ export const DocumentsDataTable = ({
@ -76,12 +76,7 @@ export const DocumentsDataTable = ({
{ {
header: 'Recipient', header: 'Recipient',
accessorKey: 'recipient', accessorKey: 'recipient',
cell: ({ row }) => ( cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />,
<StackAvatarsWithTooltip
recipients={row.original.Recipient}
documentStatus={row.original.status}
/>
),
}, },
{ {
header: 'Status', header: 'Status',

View File

@ -2,11 +2,8 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { match } from 'ts-pattern';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -26,7 +23,6 @@ type DeleteDocumentDialogProps = {
status: DocumentStatus; status: DocumentStatus;
documentTitle: string; documentTitle: string;
teamId?: number; teamId?: number;
canManageDocument: boolean;
}; };
export const DeleteDocumentDialog = ({ export const DeleteDocumentDialog = ({
@ -36,7 +32,6 @@ export const DeleteDocumentDialog = ({
status, status,
documentTitle, documentTitle,
teamId, teamId,
canManageDocument,
}: DeleteDocumentDialogProps) => { }: DeleteDocumentDialogProps) => {
const router = useRouter(); const router = useRouter();
@ -88,82 +83,47 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}> <Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Are you sure?</DialogTitle> <DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
<DialogDescription> <DialogDescription>
You are about to {canManageDocument ? 'delete' : 'hide'}{' '} Please note that this action is irreversible. Once confirmed, your document will be
<strong>"{documentTitle}"</strong> permanently deleted.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{canManageDocument ? ( {status !== DocumentStatus.DRAFT && (
<Alert variant="warning" className="-mt-1"> <div className="mt-4">
{match(status) <Input
.with(DocumentStatus.DRAFT, () => ( type="text"
<AlertDescription> value={inputValue}
Please note that this action is <strong>irreversible</strong>. Once confirmed, onChange={onInputChange}
this document will be permanently deleted. placeholder="Type 'delete' to confirm"
</AlertDescription> />
)) </div>
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
Please note that this action is <strong>irreversible</strong>.
</p>
<p className="mt-1">Once confirmed, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>Document will be permanently deleted</li>
<li>Document signing process will be cancelled</li>
<li>All inserted signatures will be voided</li>
<li>All recipients will be notified</li>
</ul>
</AlertDescription>
))
.with(DocumentStatus.COMPLETED, () => (
<AlertDescription>
<p>By deleting this document, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>The document will be hidden from your account</li>
<li>Recipients will still retain their copy of the document</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
Please contact support if you would like to revert this action.
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder="Type 'delete' to confirm"
/>
)} )}
<DialogFooter> <DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <div className="flex w-full flex-1 flex-nowrap gap-4">
Cancel <Button
</Button> type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button <Button
type="button" type="button"
loading={isLoading} loading={isLoading}
onClick={onDelete} onClick={onDelete}
disabled={!isDeleteEnabled && canManageDocument} disabled={!isDeleteEnabled}
variant="destructive" variant="destructive"
> className="flex-1"
{canManageDocument ? 'Delete' : 'Hide'} >
</Button> Delete
</Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -41,9 +41,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const page = Number(searchParams.page) || 1; const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20; const perPage = Number(searchParams.perPage) || 20;
const senderIds = parseToIntegerArray(searchParams.senderIds ?? ''); const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
const currentTeam = team const currentTeam = team ? { id: team.id, url: team.url } : undefined;
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
: undefined;
const getStatOptions: GetStatsInput = { const getStatOptions: GetStatsInput = {
user, user,

View File

@ -37,10 +37,7 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
})); }));
return ( return (
<div <div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
data-testid="empty-document-state"
>
<Icon className="h-12 w-12" strokeWidth={1.5} /> <Icon className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center"> <div className="text-center">

View File

@ -18,10 +18,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@ -37,6 +34,7 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZCreateTemplateFormSchema = z.object({ const ZCreateTemplateFormSchema = z.object({
@ -63,7 +61,8 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
resolver: zodResolver(ZCreateTemplateFormSchema), resolver: zodResolver(ZCreateTemplateFormSchema),
}); });
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation(); const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false); const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>(); const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
@ -141,7 +140,6 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
useEffect(() => { useEffect(() => {
if (!showNewTemplateDialog) { if (!showNewTemplateDialog) {
form.reset(); form.reset();
setUploadedFile(null);
} }
}, [form, showNewTemplateDialog]); }, [form, showNewTemplateDialog]);
@ -156,23 +154,20 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
<DialogContent className="w-full max-w-xl"> <DialogContent className="w-full max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>New Template</DialogTitle> <DialogTitle className="mb-4">New Template</DialogTitle>
<DialogDescription>
Templates allow you to quickly generate documents with pre-filled recipients and fields.
</DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...form}> <div>
<form onSubmit={form.handleSubmit(onSubmit)}> <Form {...form}>
<fieldset disabled={form.formState.isSubmitting} className="flex flex-col gap-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Template name</FormLabel> <FormLabel>Name your template</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input id="email" type="text" className="bg-background mt-1.5" {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
@ -185,57 +180,55 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
)} )}
/> />
<div className="mt-1.5"> <div>
{uploadedFile ? ( <Label htmlFor="template">Upload a Document</Label>
<Card gradient className="h-[40vh]">
<CardContent className="flex h-full flex-col items-center justify-center p-2">
<button
onClick={() => resetForm()}
title="Remove Template"
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<X className="h-6 w-6" />
<span className="sr-only">Remove Template</span>
</button>
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"> <div className="my-3">
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" /> {uploadedFile ? (
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" /> <Card gradient className="h-[40vh]">
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" /> <CardContent className="flex h-full flex-col items-center justify-center p-2">
</div> <button
onClick={() => resetForm()}
title="Remove Template"
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<X className="h-6 w-6" />
<span className="sr-only">Remove Template</span>
</button>
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium"> <div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
Uploaded Document <div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</p> <div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</div>
<span className="text-muted-foreground/80 mt-1 text-sm"> <p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
{uploadedFile.file.name} Uploaded Document
</span> </p>
</CardContent>
</Card> <span className="text-muted-foreground/80 mt-1 text-sm">
) : ( {uploadedFile.file.name}
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" /> </span>
)} </CardContent>
</Card>
) : (
<DocumentDropzone
className="mt-1.5 h-[40vh]"
onDrop={onFileDrop}
type="template"
/>
)}
</div>
</div> </div>
<DialogFooter> <div className="flex w-full justify-end">
<DialogClose asChild> <Button loading={isCreatingTemplate} type="submit">
<Button type="button" variant="secondary"> Create Template
Cancel
</Button>
</DialogClose>
<Button
loading={form.formState.isSubmitting}
disabled={!uploadedFile}
type="submit"
>
Create template
</Button> </Button>
</DialogFooter> </div>
</fieldset> </form>
</form> </Form>
</Form> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -131,7 +131,7 @@ export default async function CompletedSigningPage({
</div> </div>
)) ))
.with({ deletedAt: null }, () => ( .with({ deletedAt: null }, () => (
<div className="flex items-center mt-4 text-center text-blue-600"> <div className="flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" /> <Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span> <span className="text-sm">Waiting for others to sign</span>
</div> </div>

View File

@ -47,12 +47,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
]); ]);
if ( if (!document || !document.documentData || !recipient) {
!document ||
!document.documentData ||
!recipient ||
document.status === DocumentStatus.DRAFT
) {
return notFound(); return notFound();
} }

View File

@ -8,7 +8,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -16,21 +15,18 @@ import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = { export type AvatarWithRecipientProps = {
recipient: Recipient; recipient: Recipient;
documentStatus: DocumentStatus;
}; };
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) { export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard(); const [, copy] = useCopyToClipboard();
const { toast } = useToast(); const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => { const onRecipientClick = () => {
if (!signingToken) { if (!recipient.token) {
return; return;
} }
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => { void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
toast({ toast({
title: 'Copied to clipboard', title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.', description: 'The signing link has been copied to your clipboard.',
@ -41,10 +37,10 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
return ( return (
<div <div
className={cn('my-1 flex items-center gap-2', { className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': signingToken, 'cursor-pointer hover:underline': recipient.token,
})} })}
role={signingToken ? 'button' : undefined} role={recipient.token ? 'button' : undefined}
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined} title={recipient.token && 'Click to copy signing link for sending to recipient'}
onClick={onRecipientClick} onClick={onRecipientClick}
> >
<StackAvatar <StackAvatar
@ -53,15 +49,16 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)} fallbackText={recipientAbbreviation(recipient)}
/> />
<div>
<div <div
className="text-muted-foreground text-sm" className="text-muted-foreground text-sm"
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined} title="Click to copy signing link for sending to recipient"
> >
<p>{recipient.email}</p> <p>{recipient.email} </p>
<p className="text-muted-foreground/70 text-xs"> <p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p> </p>
</div>
</div> </div>
</div> </div>
); );

View File

@ -5,7 +5,7 @@ import { useRef, useState } from 'react';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentStatus, Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { AvatarWithRecipient } from './avatar-with-recipient'; import { AvatarWithRecipient } from './avatar-with-recipient';
@ -13,14 +13,12 @@ import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars'; import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = { export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
recipients: Recipient[]; recipients: Recipient[];
position?: 'top' | 'bottom'; position?: 'top' | 'bottom';
children?: React.ReactNode; children?: React.ReactNode;
}; };
export const StackAvatarsWithTooltip = ({ export const StackAvatarsWithTooltip = ({
documentStatus,
recipients, recipients,
position, position,
children, children,
@ -122,11 +120,7 @@ export const StackAvatarsWithTooltip = ({
<div> <div>
<h1 className="text-base font-medium">Waiting</h1> <h1 className="text-base font-medium">Waiting</h1>
{waitingRecipients.map((recipient: Recipient) => ( {waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient key={recipient.id} recipient={recipient} />
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))} ))}
</div> </div>
)} )}
@ -135,11 +129,7 @@ export const StackAvatarsWithTooltip = ({
<div> <div>
<h1 className="text-base font-medium">Opened</h1> <h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => ( {openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient key={recipient.id} recipient={recipient} />
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))} ))}
</div> </div>
)} )}
@ -148,11 +138,7 @@ export const StackAvatarsWithTooltip = ({
<div> <div>
<h1 className="text-base font-medium">Uncompleted</h1> <h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => ( {uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient <AvatarWithRecipient key={recipient.id} recipient={recipient} />
key={recipient.id}
recipient={recipient}
documentStatus={documentStatus}
/>
))} ))}
</div> </div>
)} )}

View File

@ -1,3 +1,5 @@
'use client';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -10,6 +12,8 @@ import { getRootHref } from '@documenso/lib/utils/params';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { CommandMenu } from '../common/command-menu';
const navigationLinks = [ const navigationLinks = [
{ {
href: '/documents', href: '/documents',
@ -21,14 +25,13 @@ const navigationLinks = [
}, },
]; ];
export type DesktopNavProps = HTMLAttributes<HTMLDivElement> & { export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
setIsCommandMenuOpen: (value: boolean) => void;
};
export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => { export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const pathname = usePathname(); const pathname = usePathname();
const params = useParams(); const params = useParams();
const [open, setOpen] = useState(false);
const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
const rootHref = getRootHref(params, { returnEmptyRootString: true }); const rootHref = getRootHref(params, { returnEmptyRootString: true });
@ -67,10 +70,12 @@ export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: Deskto
))} ))}
</div> </div>
<CommandMenu open={open} onOpenChange={setOpen} />
<Button <Button
variant="outline" variant="outline"
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg" className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
onClick={() => setIsCommandMenuOpen(true)} onClick={() => setOpen((open) => !open)}
> >
<div className="flex items-center"> <div className="flex items-center">
<Search className="mr-2 h-5 w-5" /> <Search className="mr-2 h-5 w-5" />

View File

@ -58,7 +58,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
<Logo className="h-6 w-auto" /> <Logo className="h-6 w-auto" />
</Link> </Link>
<DesktopNav setIsCommandMenuOpen={setIsCommandMenuOpen} /> <DesktopNav />
<div className="flex gap-x-4 md:ml-8"> <div className="flex gap-x-4 md:ml-8">
<MenuSwitcher user={user} teams={teams} /> <MenuSwitcher user={user} teams={teams} />

74
package-lock.json generated
View File

@ -22,7 +22,7 @@
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"playwright": "1.41.0", "playwright": "^1.43.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"
@ -4702,6 +4702,19 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/browser-chromium": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz",
"integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.43.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.40.0", "version": "1.40.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
@ -17660,11 +17673,11 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.41.0", "version": "1.43.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
"integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==", "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
"dependencies": { "dependencies": {
"playwright-core": "1.41.0" "playwright-core": "1.43.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -17676,6 +17689,17 @@
"fsevents": "2.3.2" "fsevents": "2.3.2"
} }
}, },
"node_modules/playwright-core": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
"integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/playwright/node_modules/fsevents": { "node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@ -17689,17 +17713,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/playwright/node_modules/playwright-core": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz",
"integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@ -24968,7 +24981,7 @@
"next-auth": "4.24.5", "next-auth": "4.24.5",
"oslo": "^0.17.0", "oslo": "^0.17.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"playwright": "1.41.0", "playwright": "^1.43.0",
"react": "18.2.0", "react": "18.2.0",
"remeda": "^1.27.1", "remeda": "^1.27.1",
"stripe": "^12.7.0", "stripe": "^12.7.0",
@ -24976,23 +24989,10 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/browser-chromium": "1.41.0", "@playwright/browser-chromium": "^1.43.0",
"@types/luxon": "^3.3.1" "@types/luxon": "^3.3.1"
} }
}, },
"packages/lib/node_modules/@playwright/browser-chromium": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.41.0.tgz",
"integrity": "sha512-TaHfh3rDsz4+tVKdMMo4kdFOk8/4U6cPyMXHhoiJVmhOhjHXjR0qPMoa5gz5jDGl478cn5SoXmtgKPgTDFuS0g==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.41.0"
},
"engines": {
"node": ">=16"
}
},
"packages/lib/node_modules/nanoid": { "packages/lib/node_modules/nanoid": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz",
@ -25010,18 +25010,6 @@
"node": "^14 || ^16 || >=18" "node": "^14 || ^16 || >=18"
} }
}, },
"packages/lib/node_modules/playwright-core": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz",
"integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"packages/prettier-config": { "packages/prettier-config": {
"name": "@documenso/prettier-config", "name": "@documenso/prettier-config",
"version": "0.0.0", "version": "0.0.0",

View File

@ -38,7 +38,7 @@
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"playwright": "1.41.0", "playwright": "^1.43.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"

View File

@ -11,7 +11,6 @@ import {
ZDeleteDocumentMutationSchema, ZDeleteDocumentMutationSchema,
ZDeleteFieldMutationSchema, ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema, ZDeleteRecipientMutationSchema,
ZDownloadDocumentSuccessfulSchema,
ZGetDocumentsQuerySchema, ZGetDocumentsQuerySchema,
ZSendDocumentForSigningMutationSchema, ZSendDocumentForSigningMutationSchema,
ZSuccessfulDocumentResponseSchema, ZSuccessfulDocumentResponseSchema,
@ -52,17 +51,6 @@ export const ApiContractV1 = c.router(
summary: 'Get a single document', summary: 'Get a single document',
}, },
downloadSignedDocument: {
method: 'GET',
path: '/api/v1/documents/:id/download',
responses: {
200: ZDownloadDocumentSuccessfulSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Download a signed document when the storage transport is S3',
},
createDocument: { createDocument: {
method: 'POST', method: 'POST',
path: '/api/v1/documents', path: '/api/v1/documents',

View File

@ -23,10 +23,7 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import { import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { ApiContractV1 } from './contract'; import { ApiContractV1 } from './contract';
@ -86,68 +83,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
} }
}), }),
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
try {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
return {
status: 500,
body: {
message: 'Please make sure the storage transport is set to S3.',
},
};
}
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document || !document.documentDataId) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (DocumentDataType.S3_PATH !== document.documentData.type) {
return {
status: 400,
body: {
message: 'Invalid document data type',
},
};
}
if (document.status !== DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is not completed yet.',
},
};
}
const { url } = await getPresignGetUrl(document.documentData.data);
return {
status: 200,
body: { downloadUrl: url },
};
} catch (err) {
return {
status: 500,
body: {
message: 'Error downloading the document. Please try again.',
},
};
}
}),
deleteDocument: authenticatedMiddleware(async (args, user, team) => { deleteDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params; const { id: documentId } = args.params;

View File

@ -53,10 +53,6 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
key: z.string(), key: z.string(),
}); });
export const ZDownloadDocumentSuccessfulSchema = z.object({
downloadUrl: z.string(),
});
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>; export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
export const ZCreateDocumentMutationSchema = z.object({ export const ZCreateDocumentMutationSchema = z.object({

View File

@ -45,7 +45,7 @@ test.describe('[EE_ONLY]', () => {
await page await page
.getByRole('textbox', { name: 'Email', exact: true }) .getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com'); .fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
// Display advanced settings. // Display advanced settings.
await page.getByLabel('Show advanced settings').click(); await page.getByLabel('Show advanced settings').click();
@ -82,7 +82,7 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
await page.getByPlaceholder('Name').fill('Recipient 1'); await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com'); await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2'); await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
// Advanced settings should not be visible for non EE users. // Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden(); await expect(page.getByLabel('Show advanced settings')).toBeHidden();

View File

@ -136,7 +136,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByPlaceholder('Name').fill('User 1'); await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com'); await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2'); await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2');
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();

View File

@ -8,7 +8,6 @@ import {
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication'; import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
@ -75,7 +74,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
email: sender.email, email: sender.email,
}); });
// Open document action menu. // open actions menu
await page await page
.locator('tr', { hasText: 'Document 1 - Completed' }) .locator('tr', { hasText: 'Document 1 - Completed' })
.getByRole('cell', { name: 'Download' }) .getByRole('cell', { name: 'Download' })
@ -116,7 +115,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
email: sender.email, email: sender.email,
}); });
// Open document action menu. // open actions menu
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click(); await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
// delete document // delete document
@ -136,11 +135,20 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
}); });
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible(); await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
await page.goto(`/sign/${recipient.token}`);
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
await page.goto('/documents');
await page.waitForURL('/documents');
await apiSignout({ page }); await apiSignout({ page });
} }
}); });
test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => { test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({
page,
}) => {
const { sender } = await seedDeleteDocumentsTestRequirements(); const { sender } = await seedDeleteDocumentsTestRequirements();
await apiSignin({ await apiSignin({
@ -148,10 +156,11 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
email: sender.email, email: sender.email,
}); });
// Open document action menu. // open actions menu
await page await page
.locator('tr', { hasText: 'Document 1 - Draft' }) .locator('tr', { hasText: 'Document 1 - Draft' })
.getByTestId('document-table-action-btn') .getByRole('cell', { name: 'Edit' })
.getByRole('button')
.click(); .click();
// delete document // delete document
@ -160,155 +169,4 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
await page.getByRole('button', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible(); await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
const { sender } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({
page,
}) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
// Sign into the recipient account.
await apiSignout({ page });
await apiSignin({
page,
email: recipients[0].email,
});
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({
page,
}) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
const recipientA = recipients[0];
const recipientB = recipients[1];
await apiSignin({
page,
email: recipientA.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
await page.getByRole('button', { name: 'Hide' }).click();
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
await page.getByRole('button', { name: 'Hide' }).click();
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 0);
// Sign into the sender account.
await apiSignout({ page });
await apiSignin({
page,
email: sender.email,
});
// Check document counts for sender.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
// Sign into the other recipient account.
await apiSignout({ page });
await apiSignin({
page,
email: recipientB.email,
});
// Check document counts for other recipient.
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
}); });

View File

@ -1,17 +0,0 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
await page.getByRole('tab', { name: tabName }).click();
if (tabName !== 'All') {
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
}
if (count === 0) {
await expect(page.getByTestId('empty-document-state')).toBeVisible();
return;
}
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
};

View File

@ -1,3 +1,4 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
@ -6,10 +7,24 @@ import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/se
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication'; import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
await page.getByRole('tab', { name: tabName }).click();
if (tabName !== 'All') {
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
}
if (count === 0) {
await expect(page.getByRole('main')).toContainText(`Nothing to do`);
return;
}
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
};
test('[TEAMS]: check team documents count', async ({ page }) => { test('[TEAMS]: check team documents count', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments(); const { team, teamMember2 } = await seedTeamDocuments();
@ -230,6 +245,24 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
await unseedTeam(team.url); await unseedTeam(team.url);
}); });
test('[TEAMS]: delete pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments();
await apiSignin({
page,
email: currentUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Pending', 1);
});
test('[TEAMS]: resend pending team document', async ({ page }) => { test('[TEAMS]: resend pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments(); const { team, teamMember2: currentUser } = await seedTeamDocuments();
@ -247,125 +280,3 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
await expect(page.getByRole('status')).toContainText('Document re-sent'); await expect(page.getByRole('status')).toContainText('Document re-sent');
}); });
test('[TEAMS]: delete draft team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Draft', 1);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});
test('[TEAMS]: delete pending team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Pending', 1);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});
test('[TEAMS]: delete completed team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
await page.getByRole('row').getByRole('button').nth(2).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Completed', 0);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});

View File

@ -23,10 +23,6 @@ export const TemplateDocumentCancel = ({
<br />"{documentName}" <br />"{documentName}"
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400">
All signatures have been voided.
</Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">
You don't need to sign it anymore. You don't need to sign it anymore.
</Text> </Text>

View File

@ -11,7 +11,6 @@ export interface TemplateDocumentInviteProps {
signDocumentLink: string; signDocumentLink: string;
assetBaseUrl: string; assetBaseUrl: string;
role: RecipientRole; role: RecipientRole;
selfSigner: boolean;
} }
export const TemplateDocumentInvite = ({ export const TemplateDocumentInvite = ({
@ -20,7 +19,6 @@ export const TemplateDocumentInvite = ({
signDocumentLink, signDocumentLink,
assetBaseUrl, assetBaseUrl,
role, role,
selfSigner,
}: TemplateDocumentInviteProps) => { }: TemplateDocumentInviteProps) => {
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role]; const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
@ -30,19 +28,8 @@ export const TemplateDocumentInvite = ({
<Section> <Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold"> <Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{selfSigner ? ( {inviterName} has invited you to {actionVerb.toLowerCase()}
<> <br />"{documentName}"
{`Please ${actionVerb.toLowerCase()} your document`}
<br />
{`"${documentName}"`}
</>
) : (
<>
{`${inviterName} has invited you to ${actionVerb.toLowerCase()}`}
<br />
{`"${documentName}"`}
</>
)}
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">

View File

@ -22,7 +22,6 @@ import { TemplateFooter } from '../template-components/template-footer';
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & { export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
customBody?: string; customBody?: string;
role: RecipientRole; role: RecipientRole;
selfSigner?: boolean;
}; };
export const DocumentInviteEmailTemplate = ({ export const DocumentInviteEmailTemplate = ({
@ -33,13 +32,10 @@ export const DocumentInviteEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002', assetBaseUrl = 'http://localhost:3002',
customBody, customBody,
role, role,
selfSigner = false,
}: DocumentInviteEmailTemplateProps) => { }: DocumentInviteEmailTemplateProps) => {
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase(); const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
const previewText = selfSigner const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
? `Please ${action} your document ${documentName}`
: `${inviterName} has invited you to ${action} ${documentName}`;
const getAssetUrl = (path: string) => { const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString(); return new URL(path, assetBaseUrl).toString();
@ -75,7 +71,6 @@ export const DocumentInviteEmailTemplate = ({
signDocumentLink={signDocumentLink} signDocumentLink={signDocumentLink}
assetBaseUrl={assetBaseUrl} assetBaseUrl={assetBaseUrl}
role={role} role={role}
selfSigner={selfSigner}
/> />
</Section> </Section>
</Container> </Container>

View File

@ -39,7 +39,7 @@
"next-auth": "4.24.5", "next-auth": "4.24.5",
"oslo": "^0.17.0", "oslo": "^0.17.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"playwright": "1.41.0", "playwright": "^1.43.0",
"react": "18.2.0", "react": "18.2.0",
"remeda": "^1.27.1", "remeda": "^1.27.1",
"stripe": "^12.7.0", "stripe": "^12.7.0",
@ -48,6 +48,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@playwright/browser-chromium": "1.41.0" "@playwright/browser-chromium": "^1.43.0"
} }
} }

View File

@ -49,8 +49,8 @@ export const completeDocumentWithToken = async ({
const document = await getDocument({ token, documentId }); const document = await getDocument({ token, documentId });
if (document.status !== DocumentStatus.PENDING) { if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} must be pending`); throw new Error(`Document ${document.id} has already been completed`);
} }
if (document.Recipient.length === 0) { if (document.Recipient.length === 0) {

View File

@ -6,7 +6,6 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render'; import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@ -28,178 +27,110 @@ export const deleteDocument = async ({
teamId, teamId,
requestMetadata, requestMetadata,
}: DeleteDocumentOptions) => { }: DeleteDocumentOptions) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new Error('User not found');
}
const document = await prisma.document.findUnique({ const document = await prisma.document.findUnique({
where: { where: {
id, id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
}, },
include: { include: {
Recipient: true, Recipient: true,
documentMeta: true, documentMeta: true,
team: { User: true,
select: {
members: true,
},
},
}, },
}); });
if (!document || (teamId !== undefined && teamId !== document.teamId)) { if (!document) {
throw new Error('Document not found'); throw new Error('Document not found');
} }
const isUserOwner = document.userId === userId; const { status, User: user } = document;
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email);
if (!isUserOwner && !isUserTeamMember && !userRecipient) { // if the document is a draft, hard-delete
throw new Error('Not allowed'); if (status === DocumentStatus.DRAFT) {
}
// Handle hard or soft deleting the actual document if user has permission.
if (isUserOwner || isUserTeamMember) {
await handleDocumentOwnerDelete({
document,
user,
requestMetadata,
});
}
// Continue to hide the document from the user if they are a recipient.
if (userRecipient?.documentDeletedAt === null) {
await prisma.recipient.update({
where: {
documentId_email: {
documentId: document.id,
email: user.email,
},
},
data: {
documentDeletedAt: new Date().toISOString(),
},
});
}
// Return partial document for API v1 response.
return {
id: document.id,
userId: document.userId,
teamId: document.teamId,
title: document.title,
status: document.status,
documentDataId: document.documentDataId,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
completedAt: document.completedAt,
};
};
type HandleDocumentOwnerDeleteOptions = {
document: Document & {
Recipient: Recipient[];
documentMeta: DocumentMeta | null;
};
user: User;
requestMetadata?: RequestMetadata;
};
const handleDocumentOwnerDelete = async ({
document,
user,
requestMetadata,
}: HandleDocumentOwnerDeleteOptions) => {
if (document.deletedAt) {
return;
}
// Soft delete completed documents.
if (document.status === DocumentStatus.COMPLETED) {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
// Currently redundant since deleting a document will delete the audit logs.
// However may be useful if we disassociate audit lgos and documents if required.
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
documentId: document.id, documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user, user,
requestMetadata, requestMetadata,
data: { data: {
type: 'SOFT', type: 'HARD',
}, },
}), }),
}); });
return await tx.document.update({ return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
where: {
id: document.id,
},
data: {
deletedAt: new Date().toISOString(),
},
});
}); });
} }
// Hard delete draft and pending documents. // if the document is pending, send cancellation emails to all recipients
const deletedDocument = await prisma.$transaction(async (tx) => { if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
// Currently redundant since deleting a document will delete the audit logs. await Promise.all(
// However may be useful if we disassociate audit logs and documents if required. document.Recipient.map(async (recipient) => {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
}
// If the document is not a draft, only soft-delete.
return await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
documentId: document.id, documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user, user,
requestMetadata, requestMetadata,
data: { data: {
type: 'HARD', type: 'SOFT',
}, },
}), }),
}); });
return await tx.document.delete({ return await tx.document.update({
where: { where: {
id: document.id, id,
status: { },
not: DocumentStatus.COMPLETED, data: {
}, deletedAt: new Date().toISOString(),
}, },
}); });
}); });
// Send cancellation emails to recipients.
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
return deletedDocument;
}; };

View File

@ -94,63 +94,22 @@ export const findDocuments = async ({
}; };
} }
let deletedFilter: Prisma.DocumentWhereInput = {
AND: {
OR: [
{
userId: user.id,
deletedAt: null,
},
{
Recipient: {
some: {
email: user.email,
documentDeletedAt: null,
},
},
},
],
},
};
if (team) {
deletedFilter = {
AND: {
OR: team.teamEmail
? [
{
teamId: team.id,
deletedAt: null,
},
{
User: {
email: team.teamEmail.email,
},
deletedAt: null,
},
{
Recipient: {
some: {
email: team.teamEmail.email,
documentDeletedAt: null,
},
},
},
]
: [
{
teamId: team.id,
deletedAt: null,
},
],
},
};
}
const whereClause: Prisma.DocumentWhereInput = { const whereClause: Prisma.DocumentWhereInput = {
...termFilters, ...termFilters,
...filters, ...filters,
...deletedFilter, AND: {
OR: [
{
status: ExtendedDocumentStatus.COMPLETED,
},
{
status: {
not: ExtendedDocumentStatus.COMPLETED,
},
deletedAt: null,
},
],
},
}; };
if (period) { if (period) {

View File

@ -72,7 +72,6 @@ type GetCountsOption = {
const getCounts = async ({ user, createdAt }: GetCountsOption) => { const getCounts = async ({ user, createdAt }: GetCountsOption) => {
return Promise.all([ return Promise.all([
// Owner counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -85,7 +84,6 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
deletedAt: null, deletedAt: null,
}, },
}), }),
// Not signed counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -97,13 +95,12 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
}, },
}, },
createdAt, createdAt,
deletedAt: null,
}, },
}), }),
// Has signed counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -123,9 +120,9 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null,
}, },
{ {
status: ExtendedDocumentStatus.COMPLETED, status: ExtendedDocumentStatus.COMPLETED,
@ -133,7 +130,6 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
}, },
@ -202,7 +198,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,
@ -224,7 +219,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,
@ -235,7 +229,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,

View File

@ -88,11 +88,6 @@ export const resendDocument = async ({
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient; const { email, name } = recipient;
const selfSigner = email === user.email;
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
recipient.role
].actionVerb.toLowerCase()} it.`;
const customEmailTemplate = { const customEmailTemplate = {
'signer.name': name, 'signer.name': name,
@ -109,20 +104,12 @@ export const resendDocument = async ({
inviterEmail: user.email, inviterEmail: user.email,
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate( customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role, role: recipient.role,
selfSigner,
}); });
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const emailSubject = selfSigner
? `Reminder: Please ${actionVerb.toLowerCase()} your document`
: `Reminder: Please ${actionVerb.toLowerCase()} this document`;
await prisma.$transaction( await prisma.$transaction(
async (tx) => { async (tx) => {
await mailer.sendMail({ await mailer.sendMail({
@ -136,7 +123,7 @@ export const resendDocument = async ({
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: emailSubject, : `Please ${actionVerb.toLowerCase()} this document`,
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
}); });

View File

@ -80,7 +80,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
attachments: [ attachments: [
{ {
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf', filename: document.title,
content: Buffer.from(completedDocument), content: Buffer.from(completedDocument),
}, },
], ],
@ -130,7 +130,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
attachments: [ attachments: [
{ {
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf', filename: document.title,
content: Buffer.from(completedDocument), content: Buffer.from(completedDocument),
}, },
], ],

View File

@ -127,11 +127,6 @@ export const sendDocument = async ({
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient; const { email, name } = recipient;
const selfSigner = email === user.email;
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
recipient.role
].actionVerb.toLowerCase()} it.`;
const customEmailTemplate = { const customEmailTemplate = {
'signer.name': name, 'signer.name': name,
@ -148,20 +143,12 @@ export const sendDocument = async ({
inviterEmail: user.email, inviterEmail: user.email,
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate( customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role, role: recipient.role,
selfSigner,
}); });
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const emailSubject = selfSigner
? `Please ${actionVerb.toLowerCase()} your document`
: `Please ${actionVerb.toLowerCase()} this document`;
await prisma.$transaction( await prisma.$transaction(
async (tx) => { async (tx) => {
await mailer.sendMail({ await mailer.sendMail({
@ -175,7 +162,7 @@ export const sendDocument = async ({
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: emailSubject, : `Please ${actionVerb.toLowerCase()} this document`,
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
}); });

View File

@ -36,8 +36,8 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Document not found for field ${field.id}`); throw new Error(`Document not found for field ${field.id}`);
} }
if (document.status !== DocumentStatus.PENDING) { if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} must be pending`); throw new Error(`Document ${document.id} has already been completed`);
} }
if (recipient?.signingStatus === SigningStatus.SIGNED) { if (recipient?.signingStatus === SigningStatus.SIGNED) {

View File

@ -58,12 +58,12 @@ export const signFieldWithToken = async ({
throw new Error(`Recipient not found for field ${field.id}`); throw new Error(`Recipient not found for field ${field.id}`);
} }
if (document.deletedAt) { if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has been deleted`); throw new Error(`Document ${document.id} has already been completed`);
} }
if (document.status !== DocumentStatus.PENDING) { if (document.deletedAt) {
throw new Error(`Document ${document.id} must be pending for signing`); throw new Error(`Document ${document.id} has been deleted`);
} }
if (recipient?.signingStatus === SigningStatus.SIGNED) { if (recipient?.signingStatus === SigningStatus.SIGNED) {

View File

@ -1,13 +0,0 @@
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "documentDeletedAt" TIMESTAMP(3);
-- Hard delete all PENDING documents that have been soft deleted
DELETE FROM "Document" WHERE "deletedAt" IS NOT NULL AND "status" = 'PENDING';
-- Update all recipients who are the owner of the document and where the document has deletedAt set to not null
UPDATE "Recipient"
SET "documentDeletedAt" = "Document"."deletedAt"
FROM "Document", "User"
WHERE "Recipient"."documentId" = "Document"."id"
AND "Recipient"."email" = "User"."email"
AND "Document"."deletedAt" IS NOT NULL;

View File

@ -1,23 +0,0 @@
-- DropForeignKey
ALTER TABLE "PasswordResetToken" DROP CONSTRAINT "PasswordResetToken_userId_fkey";
-- DropForeignKey
ALTER TABLE "Signature" DROP CONSTRAINT "Signature_fieldId_fkey";
-- DropForeignKey
ALTER TABLE "Team" DROP CONSTRAINT "Team_ownerUserId_fkey";
-- DropForeignKey
ALTER TABLE "TeamMember" DROP CONSTRAINT "TeamMember_userId_fkey";
-- AddForeignKey
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Signature" ADD CONSTRAINT "Signature_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "Field"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -98,7 +98,7 @@ model PasswordResetToken {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
expiry DateTime expiry DateTime
userId Int userId Int
User User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id])
} }
model Passkey { model Passkey {
@ -347,24 +347,23 @@ enum RecipientRole {
} }
model Recipient { model Recipient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
documentId Int? documentId Int?
templateId Int? templateId Int?
email String @db.VarChar(255) email String @db.VarChar(255)
name String @default("") @db.VarChar(255) name String @default("") @db.VarChar(255)
token String token String
documentDeletedAt DateTime? expired DateTime?
expired DateTime? signedAt DateTime?
signedAt DateTime? authOptions Json?
authOptions Json? role RecipientRole @default(SIGNER)
role RecipientRole @default(SIGNER) readStatus ReadStatus @default(NOT_OPENED)
readStatus ReadStatus @default(NOT_OPENED) signingStatus SigningStatus @default(NOT_SIGNED)
signingStatus SigningStatus @default(NOT_SIGNED) sendStatus SendStatus @default(NOT_SENT)
sendStatus SendStatus @default(NOT_SENT) Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Field Field[]
Field Field[] Signature Signature[]
Signature Signature[]
@@unique([documentId, email]) @@unique([documentId, email])
@@unique([templateId, email]) @@unique([templateId, email])
@ -415,7 +414,7 @@ model Signature {
typedSignature String? typedSignature String?
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade) Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Field Field @relation(fields: [fieldId], references: [id], onDelete: Cascade) Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict)
@@index([recipientId]) @@index([recipientId])
} }
@ -457,7 +456,7 @@ model Team {
emailVerification TeamEmailVerification? emailVerification TeamEmailVerification?
transferVerification TeamTransferVerification? transferVerification TeamTransferVerification?
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerUserId], references: [id])
subscription Subscription? subscription Subscription?
document Document[] document Document[]
@ -483,7 +482,7 @@ model TeamMember {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
role TeamMemberRole role TeamMemberRole
userId Int userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id])
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([userId, teamId]) @@unique([userId, teamId])
@ -564,5 +563,5 @@ model SiteSettings {
data Json data Json
lastModifiedByUserId Int? lastModifiedByUserId Int?
lastModifiedAt DateTime @default(now()) lastModifiedAt DateTime @default(now())
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id], onDelete: SetNull) lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id])
} }

View File

@ -342,15 +342,14 @@ export const seedPendingDocumentWithFullFields = async ({
}, },
}); });
const latestDocument = await prisma.document.update({ const latestDocument = updateDocumentOptions
where: { ? await prisma.document.update({
id: document.id, where: {
}, id: document.id,
data: { },
...updateDocumentOptions, data: updateDocumentOptions,
status: DocumentStatus.PENDING, })
}, : document;
});
return { return {
document: latestDocument, document: latestDocument,

View File

@ -221,10 +221,6 @@ export const documentRouter = router({
} }
}), }),
getDocumentMetaById: authenticatedProcedure
.input(ZSetSettingsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {}),
setTitleForDocument: authenticatedProcedure setTitleForDocument: authenticatedProcedure
.input(ZSetTitleForDocumentMutationSchema) .input(ZSetTitleForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@ -28,8 +28,6 @@ export const ZAddSettingsFormSchema = z.object({
ZDocumentActionAuthTypesSchema.optional(), ZDocumentActionAuthTypesSchema.optional(),
), ),
meta: z.object({ meta: z.object({
subject: z.string().optional(),
message: z.string().optional(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE), timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT), dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z redirectUrl: z

View File

@ -139,8 +139,10 @@ export const AddSignersFormPartial = ({
}; };
const onAddSelfSigner = () => { const onAddSelfSigner = () => {
const newSelfSignerId = nanoid(12);
appendSigner({ appendSigner({
formId: nanoid(12), formId: newSelfSignerId,
name: user?.name ?? '', name: user?.name ?? '',
email: user?.email ?? '', email: user?.email ?? '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
@ -247,7 +249,9 @@ export const AddSignersFormPartial = ({
'col-span-4': showAdvancedSettings, 'col-span-4': showAdvancedSettings,
})} })}
> >
{!showAdvancedSettings && index === 0 && <FormLabel>Name</FormLabel>} {!showAdvancedSettings && index === 0 && (
<FormLabel required>Name</FormLabel>
)}
<FormControl> <FormControl>
<Input <Input
@ -359,83 +363,29 @@ export const AddSignersFormPartial = ({
<SelectContent align="end"> <SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}> <SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex w-[150px] items-center"> <span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span> Signer
Needs to sign
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to sign the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">
{ROLE_ICONS[RecipientRole.APPROVER]}
</span>
Needs to approve
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to approve the document for it to
be completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Needs to view
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to view the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value={RecipientRole.CC}> <SelectItem value={RecipientRole.CC}>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex w-[150px] items-center"> <span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span> Receives copy
Receives copy </div>
</div> </SelectItem>
<Tooltip>
<TooltipTrigger> <SelectItem value={RecipientRole.APPROVER}>
<InfoIcon className="h-4 w-4" /> <div className="flex items-center">
</TooltipTrigger> <span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
<TooltipContent className="text-foreground z-9999 max-w-md p-4"> Approver
<p> </div>
The recipient is not required to take any action and </SelectItem>
receives a copy of the document after it is completed.
</p> <SelectItem value={RecipientRole.VIEWER}>
</TooltipContent> <div className="flex items-center">
</Tooltip> <span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div> </div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>

View File

@ -1,7 +1,5 @@
'use client'; 'use client';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -32,8 +30,6 @@ export type AddSubjectFormProps = {
document: DocumentWithData; document: DocumentWithData;
onSubmit: (_data: TAddSubjectFormSchema) => void; onSubmit: (_data: TAddSubjectFormSchema) => void;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setSubjectFormFields: (subject?: string, message?: string) => void;
}; };
export const AddSubjectFormPartial = ({ export const AddSubjectFormPartial = ({
@ -43,12 +39,10 @@ export const AddSubjectFormPartial = ({
document, document,
onSubmit, onSubmit,
isDocumentPdfLoaded, isDocumentPdfLoaded,
setSubjectFormFields,
}: AddSubjectFormProps) => { }: AddSubjectFormProps) => {
const { const {
register, register,
handleSubmit, handleSubmit,
getValues,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<TAddSubjectFormSchema>({ } = useForm<TAddSubjectFormSchema>({
defaultValues: { defaultValues: {
@ -63,13 +57,6 @@ export const AddSubjectFormPartial = ({
const onFormSubmit = handleSubmit(onSubmit); const onFormSubmit = handleSubmit(onSubmit);
const { currentStep, totalSteps, previousStep } = useStep(); const { currentStep, totalSteps, previousStep } = useStep();
useEffect(() => {
return () => {
const { meta } = getValues();
setSubjectFormFields(meta.subject, meta.message);
};
}, [getValues, setSubjectFormFields]);
return ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader

View File

@ -4,7 +4,7 @@ import React, { useId, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon, Plus, Trash } from 'lucide-react'; import { Plus, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
@ -25,7 +25,6 @@ import type { DocumentFlowStep } from '../document-flow/types';
import { ROLE_ICONS } from '../recipient-role-icons'; import { ROLE_ICONS } from '../recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select'; import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
@ -91,8 +90,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
}); });
const onAddPlaceholderSelfRecipient = () => { const onAddPlaceholderSelfRecipient = () => {
const newSelfSignerId = nanoid(12);
appendSigner({ appendSigner({
formId: nanoid(12), formId: newSelfSignerId,
name: user?.name ?? '', name: user?.name ?? '',
email: user?.email ?? '', email: user?.email ?? '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
@ -160,81 +161,29 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<SelectContent className="" align="end"> <SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}> <SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex w-[150px] items-center"> <span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span> Signer
Needs to sign
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to sign the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Needs to approve
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to approve the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Needs to view
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to view the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value={RecipientRole.CC}> <SelectItem value={RecipientRole.CC}>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex w-[150px] items-center"> <span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span> Receives copy
Receives copy </div>
</div> </SelectItem>
<Tooltip>
<TooltipTrigger> <SelectItem value={RecipientRole.APPROVER}>
<InfoIcon className="h-4 w-4" /> <div className="flex items-center">
</TooltipTrigger> <span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
<TooltipContent className="text-foreground z-9999 max-w-md p-4"> Approver
<p> </div>
The recipient is not required to take any action and receives a </SelectItem>
copy of the document after it is completed.
</p> <SelectItem value={RecipientRole.VIEWER}>
</TooltipContent> <div className="flex items-center">
</Tooltip> <span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div> </div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>