mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 02:01:33 +10:00
Merge branch 'main' of https://github.com/documenso/documenso into feat/redirect-templates
This commit is contained in:
@ -158,6 +158,7 @@ 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',
|
||||||
|
|||||||
@ -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, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, Team, TeamEmail, 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'>;
|
team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
|
||||||
@ -59,9 +59,10 @@ 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);
|
||||||
|
|
||||||
@ -127,7 +128,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
<DropdownMenuItem
|
||||||
|
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>
|
||||||
@ -154,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
|||||||
/>
|
/>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
{isDocumentDeletable && (
|
|
||||||
<DeleteDocumentDialog
|
<DeleteDocumentDialog
|
||||||
id={document.id}
|
id={document.id}
|
||||||
status={document.status}
|
status={document.status}
|
||||||
documentTitle={document.title}
|
documentTitle={document.title}
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
|
canManageDocument={canManageDocument}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
<DuplicateDocumentDialog
|
<DuplicateDocumentDialog
|
||||||
id={document.id}
|
id={document.id}
|
||||||
|
|||||||
@ -8,17 +8,20 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
|
import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-fields-for-document';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
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 } from '@documenso/prisma/client';
|
import type { Team, TeamEmail } 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';
|
||||||
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
||||||
|
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||||
import {
|
import {
|
||||||
DocumentStatus as DocumentStatusComponent,
|
DocumentStatus as DocumentStatusComponent,
|
||||||
FRIENDLY_STATUS_MAP,
|
FRIENDLY_STATUS_MAP,
|
||||||
@ -34,7 +37,7 @@ export type DocumentPageViewProps = {
|
|||||||
params: {
|
params: {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
team?: Team;
|
team?: Team & { teamEmail: TeamEmail | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||||
@ -83,11 +86,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
documentMeta.password = securePassword;
|
documentMeta.password = securePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipients = await getRecipientsForDocument({
|
const [recipients, completedFields] = await Promise.all([
|
||||||
|
getRecipientsForDocument({
|
||||||
documentId,
|
documentId,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
}),
|
||||||
|
getCompletedFieldsForDocument({
|
||||||
|
documentId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const documentWithRecipients = {
|
const documentWithRecipients = {
|
||||||
...document,
|
...document,
|
||||||
@ -118,11 +126,17 @@ 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 recipients={recipients} position="bottom">
|
<StackAvatarsWithTooltip
|
||||||
|
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>
|
||||||
|
|
||||||
@ -148,6 +162,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{document.status === DocumentStatus.PENDING && (
|
||||||
|
<DocumentReadOnlyFields
|
||||||
|
fields={completedFields}
|
||||||
|
documentMeta={document.documentMeta || undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
|
||||||
|
|||||||
@ -92,7 +92,11 @@ 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 recipients={recipients} position="bottom">
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={recipients}
|
||||||
|
documentStatus={document.status}
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
</StackAvatarsWithTooltip>
|
</StackAvatarsWithTooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -133,7 +133,11 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||||
<DownloadCertificateButton className="mr-2" documentId={document.id} />
|
<DownloadCertificateButton
|
||||||
|
className="mr-2"
|
||||||
|
documentId={document.id}
|
||||||
|
documentStatus={document.status}
|
||||||
|
/>
|
||||||
|
|
||||||
<DownloadAuditLogButton documentId={document.id} />
|
<DownloadAuditLogButton documentId={document.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { DownloadIcon } from 'lucide-react';
|
import { DownloadIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
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';
|
||||||
@ -10,11 +11,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
export type DownloadCertificateButtonProps = {
|
export type DownloadCertificateButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
documentStatus: DocumentStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DownloadCertificateButton = ({
|
export const DownloadCertificateButton = ({
|
||||||
className,
|
className,
|
||||||
documentId,
|
documentId,
|
||||||
|
documentStatus,
|
||||||
}: DownloadCertificateButtonProps) => {
|
}: DownloadCertificateButtonProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -69,6 +72,7 @@ export const DownloadCertificateButton = ({
|
|||||||
className={cn('w-full sm:w-auto', className)}
|
className={cn('w-full sm:w-auto', className)}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={documentStatus !== DocumentStatus.COMPLETED}
|
||||||
onClick={() => void onDownloadCertificatesClick()}
|
onClick={() => void onDownloadCertificatesClick()}
|
||||||
>
|
>
|
||||||
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
|
||||||
|
|||||||
@ -15,7 +15,6 @@ 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';
|
||||||
|
|
||||||
@ -45,7 +44,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'>;
|
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||||
@ -67,8 +66,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);
|
||||||
|
|
||||||
@ -107,14 +106,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger data-testid="document-table-action-btn">
|
||||||
<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>
|
||||||
|
|
||||||
{recipient && recipient?.role !== RecipientRole.CC && (
|
{!isDraft && 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 && (
|
||||||
@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
<DropdownMenuItem disabled={!canManageDocument || 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
|
||||||
@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled>
|
{/* No point displaying this if there's no functionality. */}
|
||||||
|
{/* <DropdownMenuItem disabled>
|
||||||
<XCircle className="mr-2 h-4 w-4" />
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
Void
|
Void
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem> */}
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
disabled={Boolean(!canManageDocument && team?.teamEmail)}
|
||||||
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
{canManageDocument ? 'Delete' : 'Hide'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||||
@ -186,7 +189,6 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
/>
|
/>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
{isDocumentDeletable && (
|
|
||||||
<DeleteDocumentDialog
|
<DeleteDocumentDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
status={row.status}
|
status={row.status}
|
||||||
@ -194,8 +196,9 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
teamId={team?.id}
|
teamId={team?.id}
|
||||||
|
canManageDocument={canManageDocument}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
<DuplicateDocumentDialog
|
<DuplicateDocumentDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export type DocumentsDataTableProps = {
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
showSenderColumn?: boolean;
|
showSenderColumn?: boolean;
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsDataTable = ({
|
export const DocumentsDataTable = ({
|
||||||
@ -76,7 +76,12 @@ export const DocumentsDataTable = ({
|
|||||||
{
|
{
|
||||||
header: 'Recipient',
|
header: 'Recipient',
|
||||||
accessorKey: 'recipient',
|
accessorKey: 'recipient',
|
||||||
cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />,
|
cell: ({ row }) => (
|
||||||
|
<StackAvatarsWithTooltip
|
||||||
|
recipients={row.original.Recipient}
|
||||||
|
documentStatus={row.original.status}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
|
|||||||
@ -2,8 +2,11 @@ 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,
|
||||||
@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = {
|
|||||||
status: DocumentStatus;
|
status: DocumentStatus;
|
||||||
documentTitle: string;
|
documentTitle: string;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
|
canManageDocument: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DeleteDocumentDialog = ({
|
export const DeleteDocumentDialog = ({
|
||||||
@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({
|
|||||||
status,
|
status,
|
||||||
documentTitle,
|
documentTitle,
|
||||||
teamId,
|
teamId,
|
||||||
|
canManageDocument,
|
||||||
}: DeleteDocumentDialogProps) => {
|
}: DeleteDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -83,33 +88,70 @@ 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 you want to delete "{documentTitle}"?</DialogTitle>
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Please note that this action is irreversible. Once confirmed, your document will be
|
You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
|
||||||
permanently deleted.
|
<strong>"{documentTitle}"</strong>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{status !== DocumentStatus.DRAFT && (
|
{canManageDocument ? (
|
||||||
<div className="mt-4">
|
<Alert variant="warning" className="-mt-1">
|
||||||
|
{match(status)
|
||||||
|
.with(DocumentStatus.DRAFT, () => (
|
||||||
|
<AlertDescription>
|
||||||
|
Please note that this action is <strong>irreversible</strong>. Once confirmed,
|
||||||
|
this document will be permanently deleted.
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.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
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
placeholder="Type 'delete' to confirm"
|
placeholder="Type 'delete' to confirm"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -117,13 +159,11 @@ export const DeleteDocumentDialog = ({
|
|||||||
type="button"
|
type="button"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={!isDeleteEnabled}
|
disabled={!isDeleteEnabled && canManageDocument}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex-1"
|
|
||||||
>
|
>
|
||||||
Delete
|
{canManageDocument ? 'Delete' : 'Hide'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -41,7 +41,9 @@ 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 ? { id: team.id, url: team.url } : undefined;
|
const currentTeam = team
|
||||||
|
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const getStatOptions: GetStatsInput = {
|
const getStatOptions: GetStatsInput = {
|
||||||
user,
|
user,
|
||||||
|
|||||||
@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
<div
|
||||||
|
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">
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-c
|
|||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
@ -37,7 +38,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
|
|
||||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||||
|
|
||||||
const [document, fields, recipient] = await Promise.all([
|
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
@ -45,9 +46,15 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }).catch(() => null),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
|
getCompletedFieldsForToken({ token }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!document || !document.documentData || !recipient) {
|
if (
|
||||||
|
!document ||
|
||||||
|
!document.documentData ||
|
||||||
|
!recipient ||
|
||||||
|
document.status === DocumentStatus.DRAFT
|
||||||
|
) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,7 +127,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
signature={user?.email === recipient.email ? user.signature : undefined}
|
signature={user?.email === recipient.email ? user.signature : undefined}
|
||||||
>
|
>
|
||||||
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
|
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
|
||||||
<SigningPageView recipient={recipient} document={document} fields={fields} />
|
<SigningPageView
|
||||||
|
recipient={recipient}
|
||||||
|
document={document}
|
||||||
|
fields={fields}
|
||||||
|
completedFields={completedFields}
|
||||||
|
/>
|
||||||
</DocumentAuthProvider>
|
</DocumentAuthProvider>
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,12 +4,14 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
|
|||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
import { DateField } from './date-field';
|
import { DateField } from './date-field';
|
||||||
@ -23,9 +25,15 @@ export type SigningPageViewProps = {
|
|||||||
document: DocumentAndSender;
|
document: DocumentAndSender;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
completedFields: CompletedField[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => {
|
export const SigningPageView = ({
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
completedFields,
|
||||||
|
}: SigningPageViewProps) => {
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
const { documentData, documentMeta } = document;
|
const { documentData, documentMeta } = document;
|
||||||
@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DocumentReadOnlyFields fields={completedFields} />
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
{fields.map((field) =>
|
{fields.map((field) =>
|
||||||
match(field.type)
|
match(field.type)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
export default function SignatureDisclosure() {
|
export default function SignatureDisclosure() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<article className="prose">
|
<article className="prose dark:prose-invert">
|
||||||
<h1>Electronic Signature Disclosure</h1>
|
<h1>Electronic Signature Disclosure</h1>
|
||||||
|
|
||||||
<h2>Welcome</h2>
|
<h2>Welcome</h2>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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';
|
||||||
|
|
||||||
@ -15,18 +16,21 @@ import { StackAvatar } from './stack-avatar';
|
|||||||
|
|
||||||
export type AvatarWithRecipientProps = {
|
export type AvatarWithRecipientProps = {
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
|
documentStatus: DocumentStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
export function AvatarWithRecipient({ recipient, documentStatus }: 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 (!recipient.token) {
|
if (!signingToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
|
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).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.',
|
||||||
@ -37,10 +41,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
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': recipient.token,
|
'cursor-pointer hover:underline': signingToken,
|
||||||
})}
|
})}
|
||||||
role={recipient.token ? 'button' : undefined}
|
role={signingToken ? 'button' : undefined}
|
||||||
title={recipient.token && 'Click to copy signing link for sending to recipient'}
|
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
|
||||||
onClick={onRecipientClick}
|
onClick={onRecipientClick}
|
||||||
>
|
>
|
||||||
<StackAvatar
|
<StackAvatar
|
||||||
@ -49,17 +53,16 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
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="Click to copy signing link for sending to recipient"
|
title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
|
||||||
>
|
>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
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 { Recipient } from '@documenso/prisma/client';
|
import type { DocumentStatus, Recipient } from '@documenso/prisma/client';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
import { AvatarWithRecipient } from './avatar-with-recipient';
|
import { AvatarWithRecipient } from './avatar-with-recipient';
|
||||||
import { StackAvatar } from './stack-avatar';
|
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,
|
||||||
}: StackAvatarsWithTooltipProps) => {
|
}: StackAvatarsWithTooltipProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const isControlled = useRef(false);
|
|
||||||
const isMouseOverTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const waitingRecipients = recipients.filter(
|
const waitingRecipients = recipients.filter(
|
||||||
(recipient) => getRecipientType(recipient) === 'waiting',
|
(recipient) => getRecipientType(recipient) === 'waiting',
|
||||||
);
|
);
|
||||||
@ -44,55 +39,13 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
(recipient) => getRecipientType(recipient) === 'unsigned',
|
(recipient) => getRecipientType(recipient) === 'unsigned',
|
||||||
);
|
);
|
||||||
|
|
||||||
const onMouseEnter = () => {
|
|
||||||
if (isMouseOverTimeout.current) {
|
|
||||||
clearTimeout(isMouseOverTimeout.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isControlled.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isMouseOverTimeout.current = setTimeout(() => {
|
|
||||||
setOpen((o) => (!o ? true : o));
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseLeave = () => {
|
|
||||||
if (isMouseOverTimeout.current) {
|
|
||||||
clearTimeout(isMouseOverTimeout.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isControlled.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setOpen((o) => (o ? false : o));
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOpenChange = (newOpen: boolean) => {
|
|
||||||
isControlled.current = newOpen;
|
|
||||||
|
|
||||||
setOpen(newOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={onOpenChange}>
|
<PopoverHover
|
||||||
<PopoverTrigger
|
trigger={children || <StackAvatars recipients={recipients} />}
|
||||||
className="flex cursor-pointer"
|
contentProps={{
|
||||||
onMouseEnter={onMouseEnter}
|
className: 'flex flex-col gap-y-5 py-2',
|
||||||
onMouseLeave={onMouseLeave}
|
side: position,
|
||||||
>
|
}}
|
||||||
{children || <StackAvatars recipients={recipients} />}
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
<PopoverContent
|
|
||||||
side={position}
|
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
className="flex flex-col gap-y-5 py-2"
|
|
||||||
>
|
>
|
||||||
{completedRecipients.length > 0 && (
|
{completedRecipients.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@ -120,7 +73,11 @@ 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 key={recipient.id} recipient={recipient} />
|
<AvatarWithRecipient
|
||||||
|
key={recipient.id}
|
||||||
|
recipient={recipient}
|
||||||
|
documentStatus={documentStatus}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -129,7 +86,11 @@ 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 key={recipient.id} recipient={recipient} />
|
<AvatarWithRecipient
|
||||||
|
key={recipient.id}
|
||||||
|
recipient={recipient}
|
||||||
|
documentStatus={documentStatus}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -138,11 +99,14 @@ 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 key={recipient.id} recipient={recipient} />
|
<AvatarWithRecipient
|
||||||
|
key={recipient.id}
|
||||||
|
recipient={recipient}
|
||||||
|
documentStatus={documentStatus}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PopoverContent>
|
</PopoverHover>
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
112
apps/web/src/components/document/document-read-only-fields.tsx
Normal file
112
apps/web/src/components/document/document-read-only-fields.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
convertToLocalSystemFormat,
|
||||||
|
} from '@documenso/lib/constants/date-formats';
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import type { DocumentMeta } from '@documenso/prisma/client';
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||||
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||||
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
|
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
|
export type DocumentReadOnlyFieldsProps = {
|
||||||
|
fields: CompletedField[];
|
||||||
|
documentMeta?: DocumentMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => {
|
||||||
|
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const handleHideField = (fieldId: string) => {
|
||||||
|
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
|
{fields.map(
|
||||||
|
(field) =>
|
||||||
|
!hiddenFieldIds[field.secondaryId] && (
|
||||||
|
<FieldRootContainer
|
||||||
|
field={field}
|
||||||
|
key={field.id}
|
||||||
|
cardClassName="border-gray-100/50 !shadow-none backdrop-blur-[1px] bg-background/90"
|
||||||
|
>
|
||||||
|
<div className="absolute -right-3 -top-3">
|
||||||
|
<PopoverHover
|
||||||
|
trigger={
|
||||||
|
<Avatar className="dark:border-border h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
|
||||||
|
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
|
||||||
|
{extractInitials(field.Recipient.name || field.Recipient.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
contentProps={{
|
||||||
|
className: 'flex w-fit flex-col py-2.5 text-sm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{field.Recipient.name
|
||||||
|
? `${field.Recipient.name} (${field.Recipient.email})`
|
||||||
|
: field.Recipient.email}{' '}
|
||||||
|
</span>
|
||||||
|
inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-2.5 h-6 text-xs focus:outline-none focus-visible:ring-0"
|
||||||
|
onClick={() => handleHideField(field.secondaryId)}
|
||||||
|
>
|
||||||
|
Hide field
|
||||||
|
</Button>
|
||||||
|
</PopoverHover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground break-all text-sm">
|
||||||
|
{match(field)
|
||||||
|
.with({ type: FieldType.SIGNATURE }, (field) =>
|
||||||
|
field.Signature?.signatureImageAsBase64 ? (
|
||||||
|
<img
|
||||||
|
src={field.Signature.signatureImageAsBase64}
|
||||||
|
alt="Signature"
|
||||||
|
className="h-full w-full object-contain dark:invert"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
|
||||||
|
{field.Signature?.typedSignature}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
|
||||||
|
() => field.customText,
|
||||||
|
)
|
||||||
|
.with({ type: FieldType.DATE }, () =>
|
||||||
|
convertToLocalSystemFormat(
|
||||||
|
field.customText,
|
||||||
|
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
|
||||||
|
.exhaustive()}
|
||||||
|
</div>
|
||||||
|
</FieldRootContainer>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ElementVisible>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -3,7 +3,7 @@ import { createTrpcContext } from '@documenso/trpc/server/context';
|
|||||||
import { appRouter } from '@documenso/trpc/server/router';
|
import { appRouter } from '@documenso/trpc/server/router';
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
maxDuration: 60,
|
maxDuration: 120,
|
||||||
api: {
|
api: {
|
||||||
bodyParser: {
|
bodyParser: {
|
||||||
sizeLimit: '50mb',
|
sizeLimit: '50mb',
|
||||||
|
|||||||
28
package-lock.json
generated
28
package-lock.json
generated
@ -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.43.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"
|
||||||
@ -4716,12 +4716,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.40.0",
|
"version": "1.43.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz",
|
||||||
"integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==",
|
"integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.40.0"
|
"playwright": "1.43.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@ -4745,12 +4745,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test/node_modules/playwright": {
|
"node_modules/@playwright/test/node_modules/playwright": {
|
||||||
"version": "1.40.0",
|
"version": "1.43.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz",
|
||||||
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==",
|
"integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.40.0"
|
"playwright-core": "1.43.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@ -4763,9 +4763,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test/node_modules/playwright-core": {
|
"node_modules/@playwright/test/node_modules/playwright-core": {
|
||||||
"version": "1.40.0",
|
"version": "1.43.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz",
|
||||||
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==",
|
"integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
@ -24981,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.43.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",
|
||||||
@ -24989,7 +24989,7 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/browser-chromium": "^1.43.0",
|
"@playwright/browser-chromium": "1.43.0",
|
||||||
"@types/luxon": "^3.3.1"
|
"@types/luxon": "^3.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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.43.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"
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
ZDeleteDocumentMutationSchema,
|
ZDeleteDocumentMutationSchema,
|
||||||
ZDeleteFieldMutationSchema,
|
ZDeleteFieldMutationSchema,
|
||||||
ZDeleteRecipientMutationSchema,
|
ZDeleteRecipientMutationSchema,
|
||||||
|
ZDownloadDocumentSuccessfulSchema,
|
||||||
ZGetDocumentsQuerySchema,
|
ZGetDocumentsQuerySchema,
|
||||||
ZSendDocumentForSigningMutationSchema,
|
ZSendDocumentForSigningMutationSchema,
|
||||||
ZSuccessfulDocumentResponseSchema,
|
ZSuccessfulDocumentResponseSchema,
|
||||||
@ -51,6 +52,17 @@ 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',
|
||||||
|
|||||||
@ -23,7 +23,10 @@ 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 { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
import {
|
||||||
|
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';
|
||||||
@ -83,6 +86,68 @@ 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;
|
||||||
|
|
||||||
@ -164,6 +229,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await upsertDocumentMeta({
|
||||||
|
documentId: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
...body.meta,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
|
});
|
||||||
|
|
||||||
const recipients = await setRecipientsForDocument({
|
const recipients = await setRecipientsForDocument({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
@ -259,10 +331,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
await upsertDocumentMeta({
|
await upsertDocumentMeta({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
subject: body.meta.subject,
|
...body.meta,
|
||||||
message: body.meta.message,
|
|
||||||
dateFormat: body.meta.dateFormat,
|
|
||||||
timezone: body.meta.timezone,
|
|
||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,6 +53,10 @@ 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({
|
||||||
|
|||||||
@ -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 }).fill('Recipient 2');
|
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).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 }).fill('Recipient 2');
|
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).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();
|
||||||
|
|||||||
@ -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 }).fill('User 2');
|
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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' });
|
||||||
|
|
||||||
@ -74,7 +75,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
|
|||||||
email: sender.email,
|
email: sender.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// open actions menu
|
// Open document action 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' })
|
||||||
@ -115,7 +116,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
|
|||||||
email: sender.email,
|
email: sender.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// open actions menu
|
// Open document action 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
|
||||||
@ -135,20 +136,11 @@ 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 a draft document should remove it without additional prompting', async ({
|
test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const { sender } = await seedDeleteDocumentsTestRequirements();
|
const { sender } = await seedDeleteDocumentsTestRequirements();
|
||||||
|
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
@ -156,11 +148,10 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
|
|||||||
email: sender.email,
|
email: sender.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
// open actions menu
|
// Open document action menu.
|
||||||
await page
|
await page
|
||||||
.locator('tr', { hasText: 'Document 1 - Draft' })
|
.locator('tr', { hasText: 'Document 1 - Draft' })
|
||||||
.getByRole('cell', { name: 'Edit' })
|
.getByTestId('document-table-action-btn')
|
||||||
.getByRole('button')
|
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
// delete document
|
// delete document
|
||||||
@ -169,4 +160,155 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
|
|||||||
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);
|
||||||
});
|
});
|
||||||
|
|||||||
17
packages/app-tests/e2e/fixtures/documents.ts
Normal file
17
packages/app-tests/e2e/fixtures/documents.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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}`);
|
||||||
|
};
|
||||||
@ -1,4 +1,3 @@
|
|||||||
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';
|
||||||
@ -7,24 +6,10 @@ 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();
|
||||||
|
|
||||||
@ -245,24 +230,6 @@ 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();
|
||||||
|
|
||||||
@ -280,3 +247,125 @@ 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);
|
||||||
|
});
|
||||||
|
|||||||
@ -23,6 +23,10 @@ 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>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export interface TemplateDocumentInviteProps {
|
|||||||
signDocumentLink: string;
|
signDocumentLink: string;
|
||||||
assetBaseUrl: string;
|
assetBaseUrl: string;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
|
selfSigner: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplateDocumentInvite = ({
|
export const TemplateDocumentInvite = ({
|
||||||
@ -19,6 +20,7 @@ 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];
|
||||||
|
|
||||||
@ -28,8 +30,19 @@ 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">
|
||||||
{inviterName} has invited you to {actionVerb.toLowerCase()}
|
{selfSigner ? (
|
||||||
<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">
|
||||||
|
|||||||
@ -22,6 +22,7 @@ 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 = ({
|
||||||
@ -32,10 +33,13 @@ 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 = `${inviterName} has invited you to ${action} ${documentName}`;
|
const previewText = selfSigner
|
||||||
|
? `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();
|
||||||
@ -71,6 +75,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
signDocumentLink={signDocumentLink}
|
signDocumentLink={signDocumentLink}
|
||||||
assetBaseUrl={assetBaseUrl}
|
assetBaseUrl={assetBaseUrl}
|
||||||
role={role}
|
role={role}
|
||||||
|
selfSigner={selfSigner}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -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.43.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.43.0"
|
"@playwright/browser-chromium": "1.43.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -49,8 +49,8 @@ export const completeDocumentWithToken = async ({
|
|||||||
|
|
||||||
const document = await getDocument({ token, documentId });
|
const document = await getDocument({ token, documentId });
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (document.status !== DocumentStatus.PENDING) {
|
||||||
throw new Error(`Document ${document.id} has already been completed`);
|
throw new Error(`Document ${document.id} must be pending`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.Recipient.length === 0) {
|
if (document.Recipient.length === 0) {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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';
|
||||||
@ -27,46 +28,132 @@ 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,
|
||||||
User: true,
|
team: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!document) {
|
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
|
||||||
throw new Error('Document not found');
|
throw new Error('Document not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status, User: user } = document;
|
const isUserOwner = document.userId === userId;
|
||||||
|
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
|
||||||
|
const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
// if the document is a draft, hard-delete
|
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
|
||||||
if (status === DocumentStatus.DRAFT) {
|
throw new Error('Not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: id,
|
documentId: document.id,
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
type: 'SOFT',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await tx.document.update({
|
||||||
|
where: {
|
||||||
|
id: document.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
deletedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard delete draft and pending documents.
|
||||||
|
const deletedDocument = await prisma.$transaction(async (tx) => {
|
||||||
|
// Currently redundant since deleting a document will delete the audit logs.
|
||||||
|
// However may be useful if we disassociate audit logs and documents if required.
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
documentId: document.id,
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||||
user,
|
user,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
@ -76,12 +163,17 @@ export const deleteDocument = async ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
|
return await tx.document.delete({
|
||||||
|
where: {
|
||||||
|
id: document.id,
|
||||||
|
status: {
|
||||||
|
not: DocumentStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// if the document is pending, send cancellation emails to all recipients
|
// Send cancellation emails to recipients.
|
||||||
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
document.Recipient.map(async (recipient) => {
|
document.Recipient.map(async (recipient) => {
|
||||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
@ -108,29 +200,6 @@ export const deleteDocument = async ({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// If the document is not a draft, only soft-delete.
|
return deletedDocument;
|
||||||
return await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.documentAuditLog.create({
|
|
||||||
data: createDocumentAuditLogData({
|
|
||||||
documentId: id,
|
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
|
||||||
user,
|
|
||||||
requestMetadata,
|
|
||||||
data: {
|
|
||||||
type: 'SOFT',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return await tx.document.update({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
deletedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -94,23 +94,64 @@ export const findDocuments = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause: Prisma.DocumentWhereInput = {
|
let deletedFilter: Prisma.DocumentWhereInput = {
|
||||||
...termFilters,
|
|
||||||
...filters,
|
|
||||||
AND: {
|
AND: {
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
status: ExtendedDocumentStatus.COMPLETED,
|
userId: user.id,
|
||||||
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: {
|
Recipient: {
|
||||||
not: ExtendedDocumentStatus.COMPLETED,
|
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,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause: Prisma.DocumentWhereInput = {
|
||||||
|
...termFilters,
|
||||||
|
...filters,
|
||||||
|
...deletedFilter,
|
||||||
|
};
|
||||||
|
|
||||||
if (period) {
|
if (period) {
|
||||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||||
|
|||||||
@ -72,6 +72,7 @@ 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: {
|
||||||
@ -84,6 +85,7 @@ 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: {
|
||||||
@ -95,12 +97,13 @@ 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: {
|
||||||
@ -120,9 +123,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,
|
||||||
@ -130,6 +133,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
|
|||||||
some: {
|
some: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
documentDeletedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -198,6 +202,7 @@ 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,
|
||||||
@ -219,6 +224,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
|||||||
some: {
|
some: {
|
||||||
email: teamEmail,
|
email: teamEmail,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
documentDeletedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
@ -229,6 +235,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
|||||||
some: {
|
some: {
|
||||||
email: teamEmail,
|
email: teamEmail,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
documentDeletedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
|||||||
@ -88,6 +88,11 @@ 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,
|
||||||
@ -104,12 +109,20 @@ export const resendDocument = async ({
|
|||||||
inviterEmail: user.email,
|
inviterEmail: user.email,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
customBody: renderCustomEmailTemplate(
|
||||||
|
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({
|
||||||
@ -123,7 +136,7 @@ export const resendDocument = async ({
|
|||||||
},
|
},
|
||||||
subject: customEmail?.subject
|
subject: customEmail?.subject
|
||||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
: emailSubject,
|
||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
||||||
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,
|
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
||||||
content: Buffer.from(completedDocument),
|
content: Buffer.from(completedDocument),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { mailer } from '@documenso/email/mailer';
|
|||||||
import { render } from '@documenso/email/render';
|
import { render } from '@documenso/email/render';
|
||||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||||
|
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
|
||||||
|
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
@ -127,6 +129,11 @@ 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,
|
||||||
@ -143,12 +150,20 @@ export const sendDocument = async ({
|
|||||||
inviterEmail: user.email,
|
inviterEmail: user.email,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
customBody: renderCustomEmailTemplate(
|
||||||
|
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({
|
||||||
@ -162,7 +177,7 @@ export const sendDocument = async ({
|
|||||||
},
|
},
|
||||||
subject: customEmail?.subject
|
subject: customEmail?.subject
|
||||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
: emailSubject,
|
||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
@ -198,6 +213,31 @@ export const sendDocument = async ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const allRecipientsHaveNoActionToTake = document.Recipient.every(
|
||||||
|
(recipient) => recipient.role === RecipientRole.CC,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allRecipientsHaveNoActionToTake) {
|
||||||
|
const updatedDocument = await updateDocument({
|
||||||
|
documentId,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
data: { status: DocumentStatus.COMPLETED },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
|
||||||
|
|
||||||
|
// Keep the return type the same for the `sendDocument` method
|
||||||
|
return await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updatedDocument = await prisma.$transaction(async (tx) => {
|
const updatedDocument = await prisma.$transaction(async (tx) => {
|
||||||
if (document.status === DocumentStatus.DRAFT) {
|
if (document.status === DocumentStatus.DRAFT) {
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type GetCompletedFieldsForDocumentOptions = {
|
||||||
|
documentId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCompletedFieldsForDocument = async ({
|
||||||
|
documentId,
|
||||||
|
}: GetCompletedFieldsForDocumentOptions) => {
|
||||||
|
return await prisma.field.findMany({
|
||||||
|
where: {
|
||||||
|
documentId,
|
||||||
|
Recipient: {
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
},
|
||||||
|
inserted: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Signature: true,
|
||||||
|
Recipient: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type GetCompletedFieldsForTokenOptions = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsForTokenOptions) => {
|
||||||
|
return await prisma.field.findMany({
|
||||||
|
where: {
|
||||||
|
Document: {
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Recipient: {
|
||||||
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
},
|
||||||
|
inserted: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Signature: true,
|
||||||
|
Recipient: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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.COMPLETED) {
|
if (document.status !== DocumentStatus.PENDING) {
|
||||||
throw new Error(`Document ${document.id} has already been completed`);
|
throw new Error(`Document ${document.id} must be pending`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||||
|
|||||||
@ -58,14 +58,14 @@ 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.status === DocumentStatus.COMPLETED) {
|
|
||||||
throw new Error(`Document ${document.id} has already been completed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.deletedAt) {
|
if (document.deletedAt) {
|
||||||
throw new Error(`Document ${document.id} has been deleted`);
|
throw new Error(`Document ${document.id} has been deleted`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.status !== DocumentStatus.PENDING) {
|
||||||
|
throw new Error(`Document ${document.id} must be pending for signing`);
|
||||||
|
}
|
||||||
|
|
||||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,9 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
|
|||||||
let browser: Browser;
|
let browser: Browser;
|
||||||
|
|
||||||
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
|
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
|
||||||
browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
|
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
|
||||||
|
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
|
||||||
|
browser = await chromium.connectOverCDP(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
|
||||||
} else {
|
} else {
|
||||||
browser = await chromium.launch();
|
browser = await chromium.launch();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,6 +102,10 @@ export const createDocumentFromTemplate = async ({
|
|||||||
|
|
||||||
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
|
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
|
||||||
|
|
||||||
|
if (!documentRecipient) {
|
||||||
|
throw new Error('Recipient not found.');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: field.type,
|
type: field.type,
|
||||||
page: field.page,
|
page: field.page,
|
||||||
@ -112,7 +116,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
customText: field.customText,
|
customText: field.customText,
|
||||||
inserted: field.inserted,
|
inserted: field.inserted,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
recipientId: documentRecipient?.id || null,
|
recipientId: documentRecipient.id,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -82,6 +82,10 @@ export const duplicateTemplate = async ({
|
|||||||
(doc) => doc.email === recipient?.email,
|
(doc) => doc.email === recipient?.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!duplicatedTemplateRecipient) {
|
||||||
|
throw new Error('Recipient not found.');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: field.type,
|
type: field.type,
|
||||||
page: field.page,
|
page: field.page,
|
||||||
@ -92,7 +96,7 @@ export const duplicateTemplate = async ({
|
|||||||
customText: field.customText,
|
customText: field.customText,
|
||||||
inserted: field.inserted,
|
inserted: field.inserted,
|
||||||
templateId: duplicatedTemplate.id,
|
templateId: duplicatedTemplate.id,
|
||||||
recipientId: duplicatedTemplateRecipient?.id || null,
|
recipientId: duplicatedTemplateRecipient.id,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export const getWebhooksByUserId = async (userId: number) => {
|
|||||||
return await prisma.webhook.findMany({
|
return await prisma.webhook.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
|
teamId: null,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: 'desc',
|
createdAt: 'desc',
|
||||||
|
|||||||
3
packages/lib/types/fields.ts
Normal file
3
packages/lib/types/fields.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token';
|
||||||
|
|
||||||
|
export type CompletedField = Awaited<ReturnType<typeof getCompletedFieldsForToken>>[number];
|
||||||
@ -17,6 +17,7 @@ export const getFlag = async (
|
|||||||
options?: GetFlagOptions,
|
options?: GetFlagOptions,
|
||||||
): Promise<TFeatureFlagValue> => {
|
): Promise<TFeatureFlagValue> => {
|
||||||
const requestHeaders = options?.requestHeaders ?? {};
|
const requestHeaders = options?.requestHeaders ?? {};
|
||||||
|
delete requestHeaders['content-length'];
|
||||||
|
|
||||||
if (!isFeatureFlagEnabled()) {
|
if (!isFeatureFlagEnabled()) {
|
||||||
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
||||||
@ -25,7 +26,7 @@ export const getFlag = async (
|
|||||||
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
|
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
|
||||||
url.searchParams.set('flag', flag);
|
url.searchParams.set('flag', flag);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
return await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
...requestHeaders,
|
...requestHeaders,
|
||||||
},
|
},
|
||||||
@ -35,9 +36,10 @@ export const getFlag = async (
|
|||||||
})
|
})
|
||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZFeatureFlagValueSchema.parse(res))
|
.then((res) => ZFeatureFlagValueSchema.parse(res))
|
||||||
.catch(() => false);
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
return response;
|
return LOCAL_FEATURE_FLAGS[flag] ?? false;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,6 +52,7 @@ export const getAllFlags = async (
|
|||||||
options?: GetFlagOptions,
|
options?: GetFlagOptions,
|
||||||
): Promise<Record<string, TFeatureFlagValue>> => {
|
): Promise<Record<string, TFeatureFlagValue>> => {
|
||||||
const requestHeaders = options?.requestHeaders ?? {};
|
const requestHeaders = options?.requestHeaders ?? {};
|
||||||
|
delete requestHeaders['content-length'];
|
||||||
|
|
||||||
if (!isFeatureFlagEnabled()) {
|
if (!isFeatureFlagEnabled()) {
|
||||||
return LOCAL_FEATURE_FLAGS;
|
return LOCAL_FEATURE_FLAGS;
|
||||||
@ -67,7 +70,10 @@ export const getAllFlags = async (
|
|||||||
})
|
})
|
||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
||||||
.catch(() => LOCAL_FEATURE_FLAGS);
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
return LOCAL_FEATURE_FLAGS;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,7 +95,10 @@ export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFla
|
|||||||
})
|
})
|
||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
|
||||||
.catch(() => LOCAL_FEATURE_FLAGS);
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
return LOCAL_FEATURE_FLAGS;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
interface GetFlagOptions {
|
interface GetFlagOptions {
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
-- 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;
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Made the column `recipientId` on table `Field` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- Drop all Fields where the recipientId is null
|
||||||
|
DELETE FROM "Field" WHERE "recipientId" IS NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Field" ALTER COLUMN "recipientId" SET NOT NULL;
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
-- 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;
|
||||||
@ -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])
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Passkey {
|
model Passkey {
|
||||||
@ -365,6 +365,7 @@ model Recipient {
|
|||||||
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?
|
||||||
@ -398,7 +399,7 @@ model Field {
|
|||||||
secondaryId String @unique @default(cuid())
|
secondaryId String @unique @default(cuid())
|
||||||
documentId Int?
|
documentId Int?
|
||||||
templateId Int?
|
templateId Int?
|
||||||
recipientId Int?
|
recipientId Int
|
||||||
type FieldType
|
type FieldType
|
||||||
page Int
|
page Int
|
||||||
positionX Decimal @default(0)
|
positionX Decimal @default(0)
|
||||||
@ -409,7 +410,7 @@ model Field {
|
|||||||
inserted Boolean
|
inserted Boolean
|
||||||
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)
|
||||||
Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||||
Signature Signature?
|
Signature Signature?
|
||||||
|
|
||||||
@@index([documentId])
|
@@index([documentId])
|
||||||
@ -426,7 +427,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: Restrict)
|
Field Field @relation(fields: [fieldId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([recipientId])
|
@@index([recipientId])
|
||||||
}
|
}
|
||||||
@ -468,7 +469,7 @@ model Team {
|
|||||||
emailVerification TeamEmailVerification?
|
emailVerification TeamEmailVerification?
|
||||||
transferVerification TeamTransferVerification?
|
transferVerification TeamTransferVerification?
|
||||||
|
|
||||||
owner User @relation(fields: [ownerUserId], references: [id])
|
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
|
||||||
subscription Subscription?
|
subscription Subscription?
|
||||||
|
|
||||||
document Document[]
|
document Document[]
|
||||||
@ -494,7 +495,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])
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, teamId])
|
@@unique([userId, teamId])
|
||||||
@ -576,5 +577,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])
|
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -342,14 +342,15 @@ export const seedPendingDocumentWithFullFields = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const latestDocument = updateDocumentOptions
|
const latestDocument = await prisma.document.update({
|
||||||
? await prisma.document.update({
|
|
||||||
where: {
|
where: {
|
||||||
id: document.id,
|
id: document.id,
|
||||||
},
|
},
|
||||||
data: updateDocumentOptions,
|
data: {
|
||||||
})
|
...updateDocumentOptions,
|
||||||
: document;
|
status: DocumentStatus.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
document: latestDocument,
|
document: latestDocument,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { DateTime } from 'luxon';
|
|||||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||||
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||||
@ -20,6 +21,7 @@ import { updateDocumentSettings } from '@documenso/lib/server-only/document/upda
|
|||||||
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
|
import { updateTitle } from '@documenso/lib/server-only/document/update-title';
|
||||||
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@ -413,6 +415,10 @@ export const documentRouter = router({
|
|||||||
teamId,
|
teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (document.status !== DocumentStatus.COMPLETED) {
|
||||||
|
throw new AppError('DOCUMENT_NOT_COMPLETE');
|
||||||
|
}
|
||||||
|
|
||||||
const encrypted = encryptSecondaryData({
|
const encrypted = encryptSecondaryData({
|
||||||
data: document.id.toString(),
|
data: document.id.toString(),
|
||||||
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export type FieldContainerPortalProps = {
|
|||||||
field: Field;
|
field: Field;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
cardClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FieldContainerPortal({
|
export function FieldContainerPortal({
|
||||||
@ -44,7 +45,7 @@ export function FieldContainerPortal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FieldRootContainer({ field, children }: FieldContainerPortalProps) {
|
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) {
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
@ -78,6 +79,7 @@ export function FieldRootContainer({ field, children }: FieldContainerPortalProp
|
|||||||
{
|
{
|
||||||
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
|
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
|
||||||
},
|
},
|
||||||
|
cardClassName,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-inserted={field.inserted ? 'true' : 'false'}
|
data-inserted={field.inserted ? 'true' : 'false'}
|
||||||
|
|||||||
@ -9,7 +9,11 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import { DocumentAccessAuth, DocumentActionAuth } from '@documenso/lib/types/document-auth';
|
import {
|
||||||
|
DocumentAccessAuth,
|
||||||
|
DocumentActionAuth,
|
||||||
|
DocumentAuth,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
@ -216,9 +220,9 @@ export const AddSettingsFormPartial = ({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
||||||
<li>
|
{/* <li>
|
||||||
<strong>Require account</strong> - The recipient must be signed in
|
<strong>Require account</strong> - The recipient must be signed in
|
||||||
</li>
|
</li> */}
|
||||||
<li>
|
<li>
|
||||||
<strong>Require passkey</strong> - The recipient must have an account
|
<strong>Require passkey</strong> - The recipient must have an account
|
||||||
and passkey configured via their settings
|
and passkey configured via their settings
|
||||||
@ -242,7 +246,9 @@ export const AddSettingsFormPartial = ({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
||||||
<SelectContent position="popper">
|
<SelectContent position="popper">
|
||||||
{Object.values(DocumentActionAuth).map((authType) => (
|
{Object.values(DocumentActionAuth)
|
||||||
|
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
|
||||||
|
.map((authType) => (
|
||||||
<SelectItem key={authType} value={authType}>
|
<SelectItem key={authType} value={authType}>
|
||||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@ -247,9 +247,7 @@ export const AddSignersFormPartial = ({
|
|||||||
'col-span-4': showAdvancedSettings,
|
'col-span-4': showAdvancedSettings,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{!showAdvancedSettings && index === 0 && (
|
{!showAdvancedSettings && index === 0 && <FormLabel>Name</FormLabel>}
|
||||||
<FormLabel required>Name</FormLabel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@ -304,10 +302,10 @@ export const AddSignersFormPartial = ({
|
|||||||
global action signing authentication method configured in
|
global action signing authentication method configured in
|
||||||
the "General Settings" step
|
the "General Settings" step
|
||||||
</li>
|
</li>
|
||||||
<li>
|
{/* <li>
|
||||||
<strong>Require account</strong> - The recipient must be
|
<strong>Require account</strong> - The recipient must be
|
||||||
signed in
|
signed in
|
||||||
</li>
|
</li> */}
|
||||||
<li>
|
<li>
|
||||||
<strong>Require passkey</strong> - The recipient must have
|
<strong>Require passkey</strong> - The recipient must have
|
||||||
an account and passkey configured via their settings
|
an account and passkey configured via their settings
|
||||||
@ -328,7 +326,9 @@ export const AddSignersFormPartial = ({
|
|||||||
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||||
<SelectItem value="-1">Inherit authentication method</SelectItem>
|
<SelectItem value="-1">Inherit authentication method</SelectItem>
|
||||||
|
|
||||||
{Object.values(RecipientActionAuth).map((authType) => (
|
{Object.values(RecipientActionAuth)
|
||||||
|
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
|
||||||
|
.map((authType) => (
|
||||||
<SelectItem key={authType} value={authType}>
|
<SelectItem key={authType} value={authType}>
|
||||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@ -30,4 +30,66 @@ const PopoverContent = React.forwardRef<
|
|||||||
|
|
||||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent };
|
type PopoverHoverProps = {
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
contentProps?: React.ComponentPropsWithoutRef<typeof PopoverContent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const isControlled = React.useRef(false);
|
||||||
|
const isMouseOver = React.useRef<boolean>(false);
|
||||||
|
|
||||||
|
const onMouseEnter = () => {
|
||||||
|
isMouseOver.current = true;
|
||||||
|
|
||||||
|
if (isControlled.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
isMouseOver.current = false;
|
||||||
|
|
||||||
|
if (isControlled.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpen(isMouseOver.current);
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenChange = (newOpen: boolean) => {
|
||||||
|
isControlled.current = newOpen;
|
||||||
|
|
||||||
|
setOpen(newOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger
|
||||||
|
className="flex cursor-pointer outline-none"
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
side="top"
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
{...contentProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverHover };
|
||||||
|
|||||||
Reference in New Issue
Block a user