Compare commits
33 Commits
feat/launc
...
v1.4.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f72b669f67 | ||
|
|
536cafde31 | ||
|
|
d052f02013 | ||
|
|
4878cf388f | ||
|
|
149f416be7 | ||
|
|
c432261dd8 | ||
|
|
1852aa4b05 | ||
|
|
a868ecf2d2 | ||
|
|
b1bb345929 | ||
|
|
3a32bc62c5 | ||
|
|
4f990a7030 | ||
|
|
e26debe836 | ||
|
|
8641884515 | ||
|
|
09b5621542 | ||
|
|
47b8cc598c | ||
|
|
e97b9b4f1c | ||
|
|
cad48236a0 | ||
|
|
1dd543247e | ||
|
|
2636d5fd16 | ||
|
|
9ed16c64d8 | ||
|
|
94e72534e0 | ||
|
|
f4c24fd944 | ||
|
|
3541a805e5 | ||
|
|
6053a4a40a | ||
|
|
cc090adce0 | ||
|
|
1676f5bf6c | ||
|
|
f514d55d27 | ||
|
|
b2cca9afb6 | ||
|
|
e2fa01509d | ||
|
|
311c8da8fc | ||
|
|
49ecfc1a2c | ||
|
|
ffee2b2c9a | ||
|
|
4aefb80989 |
@@ -85,6 +85,7 @@ export const SinglePlayerClient = () => {
|
|||||||
setFields(
|
setFields(
|
||||||
data.fields.map((field, i) => ({
|
data.fields.map((field, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
|
secondaryId: i.toString(),
|
||||||
documentId: -1,
|
documentId: -1,
|
||||||
templateId: null,
|
templateId: null,
|
||||||
recipientId: -1,
|
recipientId: -1,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export type DocumentPageViewProps = {
|
|||||||
team?: Team;
|
team?: Team;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentPageView({ params, team }: DocumentPageViewProps) {
|
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
const documentId = Number(id);
|
const documentId = Number(id);
|
||||||
@@ -128,4 +128,4 @@ export default async function DocumentPageView({ params, team }: DocumentPageVie
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export const EditDocumentForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||||
const { subject, message, timezone, dateFormat } = data.meta;
|
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendDocument({
|
await sendDocument({
|
||||||
@@ -159,8 +159,9 @@ export const EditDocumentForm = ({
|
|||||||
meta: {
|
meta: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
timezone,
|
|
||||||
dateFormat,
|
dateFormat,
|
||||||
|
timezone,
|
||||||
|
redirectUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import DocumentPageView from './document-page-view';
|
import { DocumentPageView } from './document-page-view';
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
|||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
{recipient?.role !== RecipientRole.CC && (
|
{recipient && recipient?.role !== RecipientRole.CC && (
|
||||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
{recipient?.role === RecipientRole.VIEWER && (
|
{recipient?.role === RecipientRole.VIEWER && (
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ export type DocumentsPageViewProps = {
|
|||||||
team?: Team & { teamEmail?: TeamEmail | null };
|
team?: Team & { teamEmail?: TeamEmail | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentsPageView({
|
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
|
||||||
searchParams = {},
|
|
||||||
team,
|
|
||||||
}: DocumentsPageViewProps) {
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||||
@@ -155,4 +152,4 @@ export default async function DocumentsPageView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import type { DocumentsPageViewProps } from './documents-page-view';
|
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||||
import DocumentsPageView from './documents-page-view';
|
import { DocumentsPageView } from './documents-page-view';
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
searchParams?: DocumentsPageViewProps['searchParams'];
|
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type EditTemplateFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
|
templateRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditTemplateStep = 'signers' | 'fields';
|
type EditTemplateStep = 'signers' | 'fields';
|
||||||
@@ -40,6 +41,7 @@ export const EditTemplateForm = ({
|
|||||||
fields,
|
fields,
|
||||||
user: _user,
|
user: _user,
|
||||||
documentData,
|
documentData,
|
||||||
|
templateRootPath,
|
||||||
}: EditTemplateFormProps) => {
|
}: EditTemplateFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -98,7 +100,7 @@ export const EditTemplateForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push('/templates');
|
router.push(templateRootPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
|||||||
@@ -1,81 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import type { TemplatePageViewProps } from './template-page-view';
|
||||||
import { redirect } from 'next/navigation';
|
import { TemplatePageView } from './template-page-view';
|
||||||
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
export default function TemplatePage({ params }: TemplatePageProps) {
|
||||||
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
return <TemplatePageView params={params} />;
|
||||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
|
||||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
|
||||||
|
|
||||||
import { TemplateType } from '~/components/formatter/template-type';
|
|
||||||
|
|
||||||
import { EditTemplateForm } from './edit-template';
|
|
||||||
|
|
||||||
export type TemplatePageProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
const templateId = Number(id);
|
|
||||||
|
|
||||||
if (!templateId || Number.isNaN(templateId)) {
|
|
||||||
redirect('/documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const template = await getTemplateById({
|
|
||||||
id: templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
if (!template || !template.templateDocumentData) {
|
|
||||||
redirect('/documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { templateDocumentData } = template;
|
|
||||||
|
|
||||||
const [templateRecipients, templateFields] = await Promise.all([
|
|
||||||
getRecipientsForTemplate({
|
|
||||||
templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
getFieldsForTemplate({
|
|
||||||
templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
|
||||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
|
||||||
Templates
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
|
||||||
{template.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EditTemplateForm
|
|
||||||
className="mt-8"
|
|
||||||
template={template}
|
|
||||||
user={user}
|
|
||||||
recipients={templateRecipients}
|
|
||||||
fields={templateFields}
|
|
||||||
documentData={templateDocumentData}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
||||||
|
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||||
|
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||||
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
|
import { EditTemplateForm } from './edit-template';
|
||||||
|
|
||||||
|
export type TemplatePageViewProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
team?: Team;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) => {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const templateId = Number(id);
|
||||||
|
const templateRootPath = formatTemplatesPath(team?.url);
|
||||||
|
|
||||||
|
if (!templateId || Number.isNaN(templateId)) {
|
||||||
|
redirect(templateRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const template = await getTemplateById({
|
||||||
|
id: templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!template || !template.templateDocumentData) {
|
||||||
|
redirect(templateRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { templateDocumentData } = template;
|
||||||
|
|
||||||
|
const [templateRecipients, templateFields] = await Promise.all([
|
||||||
|
getRecipientsForTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
getFieldsForTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
Templates
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||||
|
{template.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditTemplateForm
|
||||||
|
className="mt-8"
|
||||||
|
template={template}
|
||||||
|
user={user}
|
||||||
|
recipients={templateRecipients}
|
||||||
|
fields={templateFields}
|
||||||
|
documentData={templateDocumentData}
|
||||||
|
templateRootPath={templateRootPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -21,9 +21,15 @@ import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
|||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
row: Template;
|
row: Template;
|
||||||
|
templateRootPath: string;
|
||||||
|
teamId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({
|
||||||
|
row,
|
||||||
|
templateRootPath,
|
||||||
|
teamId,
|
||||||
|
}: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@@ -34,6 +40,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = row.userId === session.user.id;
|
const isOwner = row.userId === session.user.id;
|
||||||
|
const isTeamTemplate = row.teamId === teamId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -44,20 +51,25 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner} asChild>
|
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||||
<Link href={`/templates/${row.id}`}>
|
<Link href={`${templateRootPath}/${row.id}`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
|
disabled={!isOwner && !isTeamTemplate}
|
||||||
|
onClick={() => setDuplicateDialogOpen(true)}
|
||||||
|
>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
|
<DropdownMenuItem
|
||||||
|
disabled={!isOwner && !isTeamTemplate}
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -65,6 +77,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
|
|
||||||
<DuplicateTemplateDialog
|
<DuplicateTemplateDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
|
teamId={teamId}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ type TemplatesDataTableProps = {
|
|||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
|
documentRootPath: string;
|
||||||
|
templateRootPath: string;
|
||||||
|
teamId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplatesDataTable = ({
|
export const TemplatesDataTable = ({
|
||||||
@@ -35,6 +38,9 @@ export const TemplatesDataTable = ({
|
|||||||
perPage,
|
perPage,
|
||||||
page,
|
page,
|
||||||
totalPages,
|
totalPages,
|
||||||
|
documentRootPath,
|
||||||
|
templateRootPath,
|
||||||
|
teamId,
|
||||||
}: TemplatesDataTableProps) => {
|
}: TemplatesDataTableProps) => {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
@@ -70,7 +76,7 @@ export const TemplatesDataTable = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`/documents/${id}`);
|
router.push(`${documentRootPath}/${id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@@ -131,7 +137,12 @@ export const TemplatesDataTable = ({
|
|||||||
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
|
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
|
||||||
Use Template
|
Use Template
|
||||||
</Button>
|
</Button>
|
||||||
<DataTableActionDropdown row={row.original} />
|
|
||||||
|
<DataTableActionDropdown
|
||||||
|
row={row.original}
|
||||||
|
teamId={teamId}
|
||||||
|
templateRootPath={templateRootPath}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,20 +35,15 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
|
|||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
});
|
onError: () => {
|
||||||
|
|
||||||
const onDeleteTemplate = async () => {
|
|
||||||
try {
|
|
||||||
await deleteTemplate({ id });
|
|
||||||
} catch {
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
description: 'This template could not be deleted at this time. Please try again.',
|
description: 'This template could not be deleted at this time. Please try again.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
@@ -63,20 +58,18 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
disabled={isLoading}
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="flex-1"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
|
<Button type="button" loading={isLoading} onClick={async () => deleteTemplate({ id })}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
type DuplicateTemplateDialogProps = {
|
type DuplicateTemplateDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
|
teamId?: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DuplicateTemplateDialog = ({
|
export const DuplicateTemplateDialog = ({
|
||||||
id,
|
id,
|
||||||
|
teamId,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DuplicateTemplateDialogProps) => {
|
}: DuplicateTemplateDialogProps) => {
|
||||||
@@ -40,21 +42,14 @@ export const DuplicateTemplateDialog = ({
|
|||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
});
|
onError: () => {
|
||||||
|
|
||||||
const onDuplicate = async () => {
|
|
||||||
try {
|
|
||||||
await duplicateTemplate({
|
|
||||||
templateId: id,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'An error occurred while duplicating template.',
|
description: 'An error occurred while duplicating template.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
@@ -66,20 +61,27 @@ export const DuplicateTemplateDialog = ({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isLoading}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="flex-1"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={async () =>
|
||||||
|
duplicateTemplate({
|
||||||
|
templateId: id,
|
||||||
|
teamId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
Duplicate
|
Duplicate
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -43,8 +43,14 @@ const ZCreateTemplateFormSchema = z.object({
|
|||||||
|
|
||||||
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
|
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
|
||||||
|
|
||||||
export const NewTemplateDialog = () => {
|
type NewTemplateDialogProps = {
|
||||||
|
teamId?: number;
|
||||||
|
templateRootPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -99,6 +105,7 @@ export const NewTemplateDialog = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { id } = await createTemplate({
|
const { id } = await createTemplate({
|
||||||
|
teamId,
|
||||||
title: values.name ? values.name : file.name,
|
title: values.name ? values.name : file.name,
|
||||||
templateDocumentDataId,
|
templateDocumentDataId,
|
||||||
});
|
});
|
||||||
@@ -112,7 +119,7 @@ export const NewTemplateDialog = () => {
|
|||||||
|
|
||||||
setShowNewTemplateDialog(false);
|
setShowNewTemplateDialog(false);
|
||||||
|
|
||||||
void router.push(`/templates/${id}`);
|
router.push(`${templateRootPath}/${id}`);
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
|
|||||||
@@ -2,57 +2,17 @@ import React from 'react';
|
|||||||
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { TemplatesPageView } from './templates-page-view';
|
||||||
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
|
import type { TemplatesPageViewProps } from './templates-page-view';
|
||||||
|
|
||||||
import { TemplatesDataTable } from './data-table-templates';
|
|
||||||
import { EmptyTemplateState } from './empty-state';
|
|
||||||
import { NewTemplateDialog } from './new-template-dialog';
|
|
||||||
|
|
||||||
type TemplatesPageProps = {
|
type TemplatesPageProps = {
|
||||||
searchParams?: {
|
searchParams?: TemplatesPageViewProps['searchParams'];
|
||||||
page?: number;
|
|
||||||
perPage?: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Templates',
|
title: 'Templates',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
return <TemplatesPageView searchParams={searchParams} />;
|
||||||
const page = Number(searchParams.page) || 1;
|
|
||||||
const perPage = Number(searchParams.perPage) || 10;
|
|
||||||
|
|
||||||
const { templates, totalPages } = await getTemplates({
|
|
||||||
userId: user.id,
|
|
||||||
page: page,
|
|
||||||
perPage: perPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<h1 className="mb-5 mt-2 truncate text-2xl font-semibold md:text-3xl">Templates</h1>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<NewTemplateDialog />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
{templates.length > 0 ? (
|
|
||||||
<TemplatesDataTable
|
|
||||||
templates={templates}
|
|
||||||
page={page}
|
|
||||||
perPage={perPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyTemplateState />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||||
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
|
|
||||||
|
import { TemplatesDataTable } from './data-table-templates';
|
||||||
|
import { EmptyTemplateState } from './empty-state';
|
||||||
|
import { NewTemplateDialog } from './new-template-dialog';
|
||||||
|
|
||||||
|
export type TemplatesPageViewProps = {
|
||||||
|
searchParams?: {
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
};
|
||||||
|
team?: Team;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplatesPageView = async ({ searchParams = {}, team }: TemplatesPageViewProps) => {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
const page = Number(searchParams.page) || 1;
|
||||||
|
const perPage = Number(searchParams.perPage) || 10;
|
||||||
|
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
const templateRootPath = formatTemplatesPath(team?.url);
|
||||||
|
|
||||||
|
const { templates, totalPages } = await findTemplates({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
page: page,
|
||||||
|
perPage: perPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
{team && (
|
||||||
|
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||||
|
<AvatarFallback className="text-xs text-gray-400">
|
||||||
|
{team.name.slice(0, 1)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="truncate text-2xl font-semibold md:text-3xl">Templates</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NewTemplateDialog templateRootPath={templateRootPath} teamId={team?.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mt-5">
|
||||||
|
{templates.length > 0 ? (
|
||||||
|
<TemplatesDataTable
|
||||||
|
templates={templates}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
documentRootPath={documentRootPath}
|
||||||
|
templateRootPath={templateRootPath}
|
||||||
|
teamId={team?.id}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyTemplateState />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -26,9 +26,10 @@ export type SigningFormProps = {
|
|||||||
document: Document;
|
document: Document;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
redirectUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
|
export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@@ -74,7 +75,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`/sign/${recipient.token}/complete`);
|
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
<span className="text-muted-foreground">({recipient.email})</span>
|
<span className="text-muted-foreground">({recipient.email})</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="py-4">
|
<div>
|
||||||
<Label htmlFor="signature">Full Name</Label>
|
<Label htmlFor="signature">Full Name</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { headers } from 'next/headers';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@@ -8,12 +9,12 @@ 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 { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
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 { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
|
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
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';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
|
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } 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';
|
||||||
@@ -40,24 +41,26 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestHeaders = Object.fromEntries(headers().entries());
|
||||||
|
|
||||||
|
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||||
|
|
||||||
const [document, fields, recipient] = await Promise.all([
|
const [document, fields, recipient] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }).catch(() => null),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
viewedDocument({ token }).catch(() => null),
|
viewedDocument({ token, requestMetadata }).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
|
|
||||||
|
|
||||||
if (!document || !document.documentData || !recipient) {
|
if (!document || !document.documentData || !recipient) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
const { documentData } = document;
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
const { user } = await getServerComponentSession();
|
const { user } = await getServerComponentSession();
|
||||||
|
|
||||||
@@ -65,7 +68,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
document.status === DocumentStatus.COMPLETED ||
|
document.status === DocumentStatus.COMPLETED ||
|
||||||
recipient.signingStatus === SigningStatus.SIGNED
|
recipient.signingStatus === SigningStatus.SIGNED
|
||||||
) {
|
) {
|
||||||
redirect(`/sign/${token}/complete`);
|
documentMeta?.redirectUrl
|
||||||
|
? redirect(documentMeta.redirectUrl)
|
||||||
|
: redirect(`/sign/${token}/complete`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (documentMeta?.password) {
|
if (documentMeta?.password) {
|
||||||
@@ -133,7 +138,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||||
<SigningForm document={document} recipient={recipient} fields={fields} />
|
<SigningForm
|
||||||
|
document={document}
|
||||||
|
recipient={recipient}
|
||||||
|
fields={fields}
|
||||||
|
redirectUrl={documentMeta?.redirectUrl}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
import DocumentPageComponent from '~/app/(dashboard)/documents/[id]/document-page-view';
|
import { DocumentPageView } from '~/app/(dashboard)/documents/[id]/document-page-view';
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@@ -16,5 +16,5 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
|||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
return <DocumentPageComponent params={params} team={team} />;
|
return <DocumentPageView params={params} team={team} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
|||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
import type { DocumentsPageViewProps } from '~/app/(dashboard)/documents/documents-page-view';
|
import type { DocumentsPageViewProps } from '~/app/(dashboard)/documents/documents-page-view';
|
||||||
import DocumentsPageView from '~/app/(dashboard)/documents/documents-page-view';
|
import { DocumentsPageView } from '~/app/(dashboard)/documents/documents-page-view';
|
||||||
|
|
||||||
export type TeamsDocumentPageProps = {
|
export type TeamsDocumentPageProps = {
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type TeamTransferStatusProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
currentUserTeamRole: TeamMemberRole;
|
currentUserTeamRole: TeamMemberRole;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
transferVerification: TeamTransferVerification | null;
|
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TeamTransferStatus = ({
|
export const TeamTransferStatus = ({
|
||||||
|
|||||||
22
apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx
Normal file
22
apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
|
import type { TemplatePageViewProps } from '~/app/(dashboard)/templates/[id]/template-page-view';
|
||||||
|
import { TemplatePageView } from '~/app/(dashboard)/templates/[id]/template-page-view';
|
||||||
|
|
||||||
|
type TeamTemplatePageProps = {
|
||||||
|
params: TemplatePageViewProps['params'] & {
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TeamTemplatePage({ params }: TeamTemplatePageProps) {
|
||||||
|
const { teamUrl } = params;
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
|
return <TemplatePageView params={params} team={team} />;
|
||||||
|
}
|
||||||
26
apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx
Normal file
26
apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
|
import type { TemplatesPageViewProps } from '~/app/(dashboard)/templates/templates-page-view';
|
||||||
|
import { TemplatesPageView } from '~/app/(dashboard)/templates/templates-page-view';
|
||||||
|
|
||||||
|
type TeamTemplatesPageProps = {
|
||||||
|
searchParams?: TemplatesPageViewProps['searchParams'];
|
||||||
|
params: {
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TeamTemplatesPage({
|
||||||
|
searchParams = {},
|
||||||
|
params,
|
||||||
|
}: TeamTemplatesPageProps) {
|
||||||
|
const { teamUrl } = params;
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
|
return <TemplatesPageView searchParams={searchParams} team={team} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Mails } from 'lucide-react';
|
||||||
|
|
||||||
|
import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email';
|
||||||
|
|
||||||
|
export default function UnverifiedAccount() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
|
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div className="">
|
||||||
|
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
To gain access to your account, please confirm your email address by clicking on the
|
||||||
|
confirmation link from your inbox.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
If you don't find the confirmation link in your inbox, you can request a new one below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SendConfirmationEmailForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,9 +52,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline gap-x-6">
|
<div className="flex items-baseline gap-x-6">
|
||||||
{navigationLinks
|
{navigationLinks.map(({ href, label }) => (
|
||||||
.filter(({ href }) => href !== '/templates' || rootHref === '') // Remove templates for team pages.
|
|
||||||
.map(({ href, label }) => (
|
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={`${rootHref}${href}`}
|
href={`${rootHref}${href}`}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
@@ -71,6 +71,22 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role];
|
return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the redirect URL so we can switch between documents and templates page
|
||||||
|
* seemlessly between teams and personal accounts.
|
||||||
|
*/
|
||||||
|
const formatRedirectUrlOnSwitch = (teamUrl?: string) => {
|
||||||
|
const baseUrl = teamUrl ? `/t/${teamUrl}/` : '/';
|
||||||
|
|
||||||
|
const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, '');
|
||||||
|
|
||||||
|
if (currentPathname === '/templates') {
|
||||||
|
return `${baseUrl}templates`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseUrl;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -100,7 +116,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
<DropdownMenuLabel>Personal</DropdownMenuLabel>
|
<DropdownMenuLabel>Personal</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/">
|
<Link href={formatRedirectUrlOnSwitch()}>
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarFallback={formatAvatarFallback()}
|
avatarFallback={formatAvatarFallback()}
|
||||||
primaryText={user.name}
|
primaryText={user.name}
|
||||||
@@ -152,7 +168,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
|
|
||||||
{teams.map((team) => (
|
{teams.map((team) => (
|
||||||
<DropdownMenuItem asChild key={team.id}>
|
<DropdownMenuItem asChild key={team.id}>
|
||||||
<Link href={`/t/${team.url}`}>
|
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarFallback={formatAvatarFallback(team.name)}
|
avatarFallback={formatAvatarFallback(team.name)}
|
||||||
primaryText={team.name}
|
primaryText={team.name}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
|
|||||||
href: '/settings/profile',
|
href: '/settings/profile',
|
||||||
text: 'Settings',
|
text: 'Settings',
|
||||||
},
|
},
|
||||||
].filter(({ text, href }) => text !== 'Templates' || href === '/templates'); // Filter out templates for teams.
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
|
|||||||
95
apps/web/src/components/forms/send-confirmation-email.tsx
Normal file
95
apps/web/src/components/forms/send-confirmation-email.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export const ZSendConfirmationEmailFormSchema = z.object({
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSendConfirmationEmailFormSchema = z.infer<typeof ZSendConfirmationEmailFormSchema>;
|
||||||
|
|
||||||
|
export type SendConfirmationEmailFormProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFormProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<TSendConfirmationEmailFormSchema>({
|
||||||
|
values: {
|
||||||
|
email: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZSendConfirmationEmailFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
|
|
||||||
|
const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ email }: TSendConfirmationEmailFormSchema) => {
|
||||||
|
try {
|
||||||
|
await sendConfirmationEmail({ email });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Confirmation email sent',
|
||||||
|
description:
|
||||||
|
'A confirmation email has been sent, and it should arrive in your inbox shortly.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'An error occurred while sending your confirmation email',
|
||||||
|
description: 'Please try again and make sure you enter the correct email address.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className={cn('mt-6 flex w-full flex-col gap-y-4', className)}
|
||||||
|
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email address</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="email" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
|
||||||
|
<Button size="lg" type="submit" disabled={isSubmitting} loading={isSubmitting}>
|
||||||
|
Send confirmation email
|
||||||
|
</Button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -38,6 +40,8 @@ const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
|||||||
'This account appears to be using a social login method, please sign in using that method',
|
'This account appears to be using a social login method, please sign in using that method',
|
||||||
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
|
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
|
||||||
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
|
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
|
||||||
|
[ErrorCode.UNVERIFIED_EMAIL]:
|
||||||
|
'This account has not been verified. Please verify your account before signing in.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
|
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
|
||||||
@@ -63,6 +67,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
'totp' | 'backup'
|
'totp' | 'backup'
|
||||||
@@ -130,6 +135,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
|
|
||||||
const errorMessage = ERROR_MESSAGES[result.error];
|
const errorMessage = ERROR_MESSAGES[result.error];
|
||||||
|
|
||||||
|
if (result.error === ErrorCode.UNVERIFIED_EMAIL) {
|
||||||
|
router.push(`/unverified-account`);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Unable to sign in',
|
||||||
|
description: errorMessage ?? 'An unknown error occurred',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: 'Unable to sign in',
|
title: 'Unable to sign in',
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -55,6 +57,7 @@ export type SignUpFormProps = {
|
|||||||
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
|
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const form = useForm<TSignUpFormSchema>({
|
const form = useForm<TSignUpFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
@@ -74,10 +77,13 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
try {
|
try {
|
||||||
await signup({ name, email, password, signature });
|
await signup({ name, email, password, signature });
|
||||||
|
|
||||||
await signIn('credentials', {
|
router.push(`/unverified-account`);
|
||||||
email,
|
|
||||||
password,
|
toast({
|
||||||
callbackUrl: SIGN_UP_REDIRECT_PATH,
|
title: 'Registration Successful',
|
||||||
|
description:
|
||||||
|
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
|
||||||
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: User Sign Up', {
|
analytics.capture('App: User Sign Up', {
|
||||||
|
|||||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -14503,6 +14503,7 @@
|
|||||||
"version": "6.9.7",
|
"version": "6.9.7",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz",
|
||||||
"integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==",
|
"integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
@@ -19495,14 +19496,14 @@
|
|||||||
"@react-email/section": "0.0.10",
|
"@react-email/section": "0.0.10",
|
||||||
"@react-email/tailwind": "0.0.9",
|
"@react-email/tailwind": "0.0.9",
|
||||||
"@react-email/text": "0.0.6",
|
"@react-email/text": "0.0.6",
|
||||||
"nodemailer": "^6.9.3",
|
"nodemailer": "^6.9.9",
|
||||||
"react-email": "^1.9.5",
|
"react-email": "^1.9.5",
|
||||||
"resend": "^2.0.0"
|
"resend": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/tsconfig": "*",
|
"@documenso/tsconfig": "*",
|
||||||
"@types/nodemailer": "^6.4.8",
|
"@types/nodemailer": "^6.4.14",
|
||||||
"tsup": "^7.1.0"
|
"tsup": "^7.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -19520,6 +19521,14 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/email/node_modules/nodemailer": {
|
||||||
|
"version": "6.9.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz",
|
||||||
|
"integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/eslint-config": {
|
"packages/eslint-config": {
|
||||||
"name": "@documenso/eslint-config",
|
"name": "@documenso/eslint-config",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
|||||||
205
packages/app-tests/e2e/templates/manage-templates.spec.ts
Normal file
205
packages/app-tests/e2e/templates/manage-templates.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
|
||||||
|
import { manualLogin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[TEMPLATES]: view templates', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const owner = team.owner;
|
||||||
|
const teamMemberUser = team.members[1].user;
|
||||||
|
|
||||||
|
// Should only be visible to the owner in personal templates.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Personal template',
|
||||||
|
userId: owner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be visible to team members.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Team template 1',
|
||||||
|
userId: owner.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be visible to team members.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Team template 2',
|
||||||
|
userId: teamMemberUser.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: owner.email,
|
||||||
|
redirectPath: '/templates',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Owner should see both team templates.
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||||
|
await expect(page.getByRole('main')).toContainText('Showing 2 results');
|
||||||
|
|
||||||
|
// Only should only see their personal template.
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/templates`);
|
||||||
|
await expect(page.getByRole('main')).toContainText('Showing 1 result');
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const owner = team.owner;
|
||||||
|
const teamMemberUser = team.members[1].user;
|
||||||
|
|
||||||
|
// Should only be visible to the owner in personal templates.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Personal template',
|
||||||
|
userId: owner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be visible to team members.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Team template 1',
|
||||||
|
userId: owner.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be visible to team members.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Team template 2',
|
||||||
|
userId: teamMemberUser.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: owner.email,
|
||||||
|
redirectPath: '/templates',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Owner should be able to delete their personal template.
|
||||||
|
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Team member should be able to delete all templates.
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||||
|
|
||||||
|
for (const template of ['Team template 1', 'Team template 2']) {
|
||||||
|
await page
|
||||||
|
.getByRole('row', { name: template })
|
||||||
|
.getByRole('cell', { name: 'Use Template' })
|
||||||
|
.getByRole('button')
|
||||||
|
.nth(1)
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATES]: duplicate template', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const owner = team.owner;
|
||||||
|
const teamMemberUser = team.members[1].user;
|
||||||
|
|
||||||
|
// Should only be visible to the owner in personal templates.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Personal template',
|
||||||
|
userId: owner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be visible to team members.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Team template 1',
|
||||||
|
userId: teamMemberUser.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: owner.email,
|
||||||
|
redirectPath: '/templates',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Duplicate personal template.
|
||||||
|
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||||
|
await expect(page.getByText('Template duplicated').first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('main')).toContainText('Showing 2 results');
|
||||||
|
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||||
|
|
||||||
|
// Duplicate team template.
|
||||||
|
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||||
|
await expect(page.getByText('Template duplicated').first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('main')).toContainText('Showing 2 results');
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATES]: use template', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const owner = team.owner;
|
||||||
|
const teamMemberUser = team.members[1].user;
|
||||||
|
|
||||||
|
// Should only be visible to the owner in personal templates.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Personal template',
|
||||||
|
userId: owner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be visible to team members.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Team template 1',
|
||||||
|
userId: teamMemberUser.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: owner.email,
|
||||||
|
redirectPath: '/templates',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use personal template.
|
||||||
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await page.waitForURL(/documents/);
|
||||||
|
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
await expect(page.getByRole('main')).toContainText('Showing 1 result');
|
||||||
|
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||||
|
|
||||||
|
// Use team template.
|
||||||
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await page.waitForURL(/\/t\/.+\/documents/);
|
||||||
|
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||||
|
await page.waitForURL(`/t/${team.url}/documents`);
|
||||||
|
await expect(page.getByRole('main')).toContainText('Showing 1 result');
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
@@ -35,14 +35,14 @@
|
|||||||
"@react-email/section": "0.0.10",
|
"@react-email/section": "0.0.10",
|
||||||
"@react-email/tailwind": "0.0.9",
|
"@react-email/tailwind": "0.0.9",
|
||||||
"@react-email/text": "0.0.6",
|
"@react-email/text": "0.0.6",
|
||||||
"nodemailer": "^6.9.3",
|
"nodemailer": "^6.9.9",
|
||||||
"react-email": "^1.9.5",
|
"react-email": "^1.9.5",
|
||||||
"resend": "^2.0.0"
|
"resend": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/tsconfig": "*",
|
"@documenso/tsconfig": "*",
|
||||||
"@types/nodemailer": "^6.4.8",
|
"@types/nodemailer": "^6.4.14",
|
||||||
"tsup": "^7.1.0"
|
"tsup": "^7.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,3 +24,9 @@ export const RECIPIENT_ROLES_DESCRIPTION: {
|
|||||||
roleName: 'Viewer',
|
roleName: 'Viewer',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
||||||
|
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
|
||||||
|
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
|
||||||
|
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+$');
|
export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+$');
|
||||||
|
export const TEAM_URL_REGEX = new RegExp('^/t/[^/]+');
|
||||||
|
|
||||||
export const TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, string> = {
|
export const TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, string> = {
|
||||||
ADMIN: 'Admin',
|
ADMIN: 'Admin',
|
||||||
|
|||||||
2
packages/lib/constants/url-regex.ts
Normal file
2
packages/lib/constants/url-regex.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const URL_REGEX =
|
||||||
|
/^(https?):\/\/(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i;
|
||||||
@@ -13,7 +13,9 @@ import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/cl
|
|||||||
|
|
||||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||||
|
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
|
||||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||||
|
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
|
||||||
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
||||||
import { ErrorCode } from './error-codes';
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
@@ -90,6 +92,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.emailVerified) {
|
||||||
|
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
!mostRecentToken ||
|
||||||
|
mostRecentToken.expires.valueOf() <= Date.now() ||
|
||||||
|
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
|
||||||
|
) {
|
||||||
|
await sendConfirmationToken({ email });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(ErrorCode.UNVERIFIED_EMAIL);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: Number(user.id),
|
id: Number(user.id),
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ export const ErrorCode = {
|
|||||||
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
|
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
|
||||||
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
|
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
|
||||||
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
|
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
|
||||||
|
UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentDataType } from '@documenso/prisma/client';
|
import type { DocumentDataType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type CreateDocumentDataOptions = {
|
export type CreateDocumentDataOptions = {
|
||||||
type: DocumentDataType;
|
type: DocumentDataType;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import {
|
||||||
|
createDocumentAuditLogData,
|
||||||
|
diffDocumentMetaChanges,
|
||||||
|
} from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
export type CreateDocumentMetaOptions = {
|
export type CreateDocumentMetaOptions = {
|
||||||
@@ -9,7 +15,9 @@ export type CreateDocumentMetaOptions = {
|
|||||||
timezone?: string;
|
timezone?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
dateFormat?: string;
|
dateFormat?: string;
|
||||||
|
redirectUrl?: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
requestMetadata: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const upsertDocumentMeta = async ({
|
export const upsertDocumentMeta = async ({
|
||||||
@@ -18,47 +26,81 @@ export const upsertDocumentMeta = async ({
|
|||||||
timezone,
|
timezone,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
documentId,
|
documentId,
|
||||||
userId,
|
|
||||||
password,
|
password,
|
||||||
|
userId,
|
||||||
|
redirectUrl,
|
||||||
|
requestMetadata,
|
||||||
}: CreateDocumentMetaOptions) => {
|
}: CreateDocumentMetaOptions) => {
|
||||||
await prisma.document.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { documentMeta: originalDocumentMeta } = await prisma.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
userId,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
team: {
|
team: {
|
||||||
members: {
|
members: {
|
||||||
some: {
|
some: {
|
||||||
userId,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
documentMeta: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return await prisma.documentMeta.upsert({
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const upsertedDocumentMeta = await tx.documentMeta.upsert({
|
||||||
where: {
|
where: {
|
||||||
documentId,
|
documentId,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
|
password,
|
||||||
dateFormat,
|
dateFormat,
|
||||||
timezone,
|
timezone,
|
||||||
password,
|
|
||||||
documentId,
|
documentId,
|
||||||
|
redirectUrl,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
dateFormat,
|
|
||||||
password,
|
password,
|
||||||
|
dateFormat,
|
||||||
timezone,
|
timezone,
|
||||||
|
redirectUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
||||||
|
documentId,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return upsertedDocumentMeta;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
@@ -9,11 +12,13 @@ import { sendPendingEmail } from './send-pending-email';
|
|||||||
export type CompleteDocumentWithTokenOptions = {
|
export type CompleteDocumentWithTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const completeDocumentWithToken = async ({
|
export const completeDocumentWithToken = async ({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
|
requestMetadata,
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
@@ -70,6 +75,24 @@ export const completeDocumentWithToken = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prisma.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||||
|
documentId: document.id,
|
||||||
|
user: {
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
},
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
recipientRole: recipient.role,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const pendingRecipients = await prisma.recipient.count({
|
const pendingRecipients = await prisma.recipient.count({
|
||||||
where: {
|
where: {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
@@ -99,6 +122,6 @@ export const completeDocumentWithToken = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (documents.count > 0) {
|
if (documents.count > 0) {
|
||||||
await sealDocument({ documentId: document.id });
|
await sealDocument({ documentId: document.id, requestMetadata });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
export type CreateDocumentOptions = {
|
export type CreateDocumentOptions = {
|
||||||
@@ -7,6 +11,7 @@ export type CreateDocumentOptions = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
documentDataId: string;
|
documentDataId: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createDocument = async ({
|
export const createDocument = async ({
|
||||||
@@ -14,22 +19,30 @@ export const createDocument = async ({
|
|||||||
title,
|
title,
|
||||||
documentDataId,
|
documentDataId,
|
||||||
teamId,
|
teamId,
|
||||||
|
requestMetadata,
|
||||||
}: CreateDocumentOptions) => {
|
}: CreateDocumentOptions) => {
|
||||||
return await prisma.$transaction(async (tx) => {
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
if (teamId !== undefined) {
|
|
||||||
await tx.team.findFirstOrThrow({
|
|
||||||
where: {
|
where: {
|
||||||
id: teamId,
|
id: userId,
|
||||||
members: {
|
},
|
||||||
some: {
|
include: {
|
||||||
userId,
|
teamMembers: {
|
||||||
|
select: {
|
||||||
|
teamId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
teamId !== undefined &&
|
||||||
|
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
|
||||||
|
) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await tx.document.create({
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const document = await tx.document.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
documentDataId,
|
documentDataId,
|
||||||
@@ -37,5 +50,19 @@ export const createDocument = async ({
|
|||||||
teamId,
|
teamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||||
|
documentId: document.id,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return document;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const duplicateDocumentById = async ({
|
|||||||
dateFormat: true,
|
dateFormat: true,
|
||||||
password: true,
|
password: true,
|
||||||
timezone: true,
|
timezone: true,
|
||||||
|
redirectUrl: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const getDocumentAndSenderByToken = async ({
|
|||||||
include: {
|
include: {
|
||||||
User: true,
|
User: true,
|
||||||
documentData: true,
|
documentData: true,
|
||||||
|
documentMeta: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ 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 {
|
||||||
|
RECIPIENT_ROLES_DESCRIPTION,
|
||||||
|
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||||
|
} from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import type { Prisma } from '@documenso/prisma/client';
|
import type { Prisma } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
|
||||||
import { getDocumentWhereInput } from './get-document-by-id';
|
import { getDocumentWhereInput } from './get-document-by-id';
|
||||||
|
|
||||||
export type ResendDocumentOptions = {
|
export type ResendDocumentOptions = {
|
||||||
@@ -17,6 +23,7 @@ export type ResendDocumentOptions = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
recipients: number[];
|
recipients: number[];
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
|
requestMetadata: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resendDocument = async ({
|
export const resendDocument = async ({
|
||||||
@@ -24,6 +31,7 @@ export const resendDocument = async ({
|
|||||||
userId,
|
userId,
|
||||||
recipients,
|
recipients,
|
||||||
teamId,
|
teamId,
|
||||||
|
requestMetadata,
|
||||||
}: ResendDocumentOptions) => {
|
}: ResendDocumentOptions) => {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -76,6 +84,8 @@ export const resendDocument = async ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||||
|
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
|
|
||||||
const customEmailTemplate = {
|
const customEmailTemplate = {
|
||||||
@@ -99,6 +109,7 @@ export const resendDocument = async ({
|
|||||||
|
|
||||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
to: {
|
to: {
|
||||||
address: email,
|
address: email,
|
||||||
@@ -114,6 +125,24 @@ export const resendDocument = async ({
|
|||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||||
|
documentId: document.id,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
emailType: recipientEmailType,
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientRole: recipient.role,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
isResending: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import path from 'node:path';
|
|||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { signPdf } from '@documenso/signing';
|
import { signPdf } from '@documenso/signing';
|
||||||
|
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { getFile } from '../../universal/upload/get-file';
|
import { getFile } from '../../universal/upload/get-file';
|
||||||
import { putFile } from '../../universal/upload/put-file';
|
import { putFile } from '../../universal/upload/put-file';
|
||||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||||
@@ -17,9 +20,14 @@ import { sendCompletedEmail } from './send-completed-email';
|
|||||||
export type SealDocumentOptions = {
|
export type SealDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
sendEmail?: boolean;
|
sendEmail?: boolean;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumentOptions) => {
|
export const sealDocument = async ({
|
||||||
|
documentId,
|
||||||
|
sendEmail = true,
|
||||||
|
requestMetadata,
|
||||||
|
}: SealDocumentOptions) => {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
const document = await prisma.document.findFirstOrThrow({
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
@@ -100,7 +108,8 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.documentData.update({
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.documentData.update({
|
||||||
where: {
|
where: {
|
||||||
id: documentData.id,
|
id: documentData.id,
|
||||||
},
|
},
|
||||||
@@ -109,7 +118,20 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||||
|
documentId: document.id,
|
||||||
|
requestMetadata,
|
||||||
|
user: null,
|
||||||
|
data: {
|
||||||
|
transactionId: nanoid(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (sendEmail) {
|
if (sendEmail) {
|
||||||
await sendCompletedEmail({ documentId });
|
await sendCompletedEmail({ documentId, requestMetadata });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import { render } from '@documenso/email/render';
|
|||||||
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { getFile } from '../../universal/upload/get-file';
|
import { getFile } from '../../universal/upload/get-file';
|
||||||
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
|
|
||||||
export interface SendDocumentOptions {
|
export interface SendDocumentOptions {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => {
|
export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDocumentOptions) => {
|
||||||
const document = await prisma.document.findUnique({
|
const document = await prisma.document.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
@@ -44,6 +48,7 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
|
|||||||
downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`,
|
downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
to: {
|
to: {
|
||||||
address: email,
|
address: email,
|
||||||
@@ -63,6 +68,24 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||||
|
documentId: document.id,
|
||||||
|
user: null,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
emailType: 'DOCUMENT_COMPLETED',
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
recipientRole: recipient.role,
|
||||||
|
isResending: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,22 +4,37 @@ 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 {
|
||||||
|
RECIPIENT_ROLES_DESCRIPTION,
|
||||||
|
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||||
|
} from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
|
||||||
|
|
||||||
export type SendDocumentOptions = {
|
export type SendDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
|
export const sendDocument = async ({
|
||||||
|
documentId,
|
||||||
|
userId,
|
||||||
|
requestMetadata,
|
||||||
|
}: SendDocumentOptions) => {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const document = await prisma.document.findUnique({
|
const document = await prisma.document.findUnique({
|
||||||
@@ -66,6 +81,8 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||||
|
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
|
|
||||||
const customEmailTemplate = {
|
const customEmailTemplate = {
|
||||||
@@ -89,6 +106,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
|
|
||||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
to: {
|
to: {
|
||||||
address: email,
|
address: email,
|
||||||
@@ -105,7 +123,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.recipient.update({
|
await tx.recipient.update({
|
||||||
where: {
|
where: {
|
||||||
id: recipient.id,
|
id: recipient.id,
|
||||||
},
|
},
|
||||||
@@ -113,6 +131,24 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
sendStatus: SendStatus.SENT,
|
sendStatus: SendStatus.SENT,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||||
|
documentId: document.id,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
emailType: recipientEmailType,
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientRole: recipient.role,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
isResending: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
export type UpdateTitleOptions = {
|
export type UpdateTitleOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOptions) => {
|
export const updateTitle = async ({
|
||||||
return await prisma.document.update({
|
userId,
|
||||||
|
documentId,
|
||||||
|
title,
|
||||||
|
requestMetadata,
|
||||||
|
}: UpdateTitleOptions) => {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const document = await tx.document.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
OR: [
|
OR: [
|
||||||
@@ -27,8 +43,34 @@ export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOpti
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.title === title) {
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDocument = await tx.document.update({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
|
||||||
|
documentId,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
from: document.title,
|
||||||
|
to: updatedDocument.title,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedDocument;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { ReadStatus } from '@documenso/prisma/client';
|
import { ReadStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type ViewedDocumentOptions = {
|
export type ViewedDocumentOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
|
export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => {
|
||||||
const recipient = await prisma.recipient.findFirst({
|
const recipient = await prisma.recipient.findFirst({
|
||||||
where: {
|
where: {
|
||||||
token,
|
token,
|
||||||
@@ -13,11 +17,14 @@ export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient || !recipient.documentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.recipient.update({
|
const { documentId } = recipient;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.recipient.update({
|
||||||
where: {
|
where: {
|
||||||
id: recipient.id,
|
id: recipient.id,
|
||||||
},
|
},
|
||||||
@@ -25,4 +32,23 @@ export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
|
|||||||
readStatus: ReadStatus.OPENED,
|
readStatus: ReadStatus.OPENED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||||
|
documentId,
|
||||||
|
user: {
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
},
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientRole: recipient.role,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,8 +10,21 @@ export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForT
|
|||||||
where: {
|
where: {
|
||||||
templateId,
|
templateId,
|
||||||
Template: {
|
Template: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
id: 'asc',
|
id: 'asc',
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type RemovedSignedFieldWithTokenOptions = {
|
export type RemovedSignedFieldWithTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
fieldId: number;
|
fieldId: number;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeSignedFieldWithToken = async ({
|
export const removeSignedFieldWithToken = async ({
|
||||||
token,
|
token,
|
||||||
fieldId,
|
fieldId,
|
||||||
|
requestMetadata,
|
||||||
}: RemovedSignedFieldWithTokenOptions) => {
|
}: RemovedSignedFieldWithTokenOptions) => {
|
||||||
const field = await prisma.field.findFirstOrThrow({
|
const field = await prisma.field.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -44,8 +49,8 @@ export const removeSignedFieldWithToken = async ({
|
|||||||
throw new Error(`Field ${fieldId} has no recipientId`);
|
throw new Error(`Field ${fieldId} has no recipientId`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await prisma.$transaction(async (tx) => {
|
||||||
prisma.field.update({
|
await tx.field.update({
|
||||||
where: {
|
where: {
|
||||||
id: field.id,
|
id: field.id,
|
||||||
},
|
},
|
||||||
@@ -53,11 +58,28 @@ export const removeSignedFieldWithToken = async ({
|
|||||||
customText: '',
|
customText: '',
|
||||||
inserted: false,
|
inserted: false,
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
prisma.signature.deleteMany({
|
|
||||||
|
await tx.signature.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||||
|
documentId: document.id,
|
||||||
|
user: {
|
||||||
|
name: recipient?.name,
|
||||||
|
email: recipient?.email,
|
||||||
|
},
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
field: field.type,
|
||||||
|
fieldId: field.secondaryId,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import {
|
||||||
|
createDocumentAuditLogData,
|
||||||
|
diffFieldChanges,
|
||||||
|
} from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { FieldType } from '@documenso/prisma/client';
|
import type { FieldType } from '@documenso/prisma/client';
|
||||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
@@ -15,12 +21,14 @@ export interface SetFieldsForDocumentOptions {
|
|||||||
pageWidth: number;
|
pageWidth: number;
|
||||||
pageHeight: number;
|
pageHeight: number;
|
||||||
}[];
|
}[];
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setFieldsForDocument = async ({
|
export const setFieldsForDocument = async ({
|
||||||
userId,
|
userId,
|
||||||
documentId,
|
documentId,
|
||||||
fields,
|
fields,
|
||||||
|
requestMetadata,
|
||||||
}: SetFieldsForDocumentOptions) => {
|
}: SetFieldsForDocumentOptions) => {
|
||||||
const document = await prisma.document.findFirst({
|
const document = await prisma.document.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -42,10 +50,25 @@ export const setFieldsForDocument = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
throw new Error('Document not found');
|
throw new Error('Document not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.completedAt) {
|
||||||
|
throw new Error('Document already complete');
|
||||||
|
}
|
||||||
|
|
||||||
const existingFields = await prisma.field.findMany({
|
const existingFields = await prisma.field.findMany({
|
||||||
where: {
|
where: {
|
||||||
documentId,
|
documentId,
|
||||||
@@ -75,11 +98,12 @@ export const setFieldsForDocument = async ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistedFields = await prisma.$transaction(
|
const persistedFields = await prisma.$transaction(async (tx) => {
|
||||||
// Disabling as wrapping promises here causes type issues
|
await Promise.all(
|
||||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
linkedFields.map(async (field) => {
|
||||||
linkedFields.map((field) =>
|
const fieldSignerEmail = field.signerEmail.toLowerCase();
|
||||||
prisma.field.upsert({
|
|
||||||
|
const upsertedField = await tx.field.upsert({
|
||||||
where: {
|
where: {
|
||||||
id: field._persisted?.id ?? -1,
|
id: field._persisted?.id ?? -1,
|
||||||
documentId,
|
documentId,
|
||||||
@@ -109,23 +133,89 @@ export const setFieldsForDocument = async ({
|
|||||||
connect: {
|
connect: {
|
||||||
documentId_email: {
|
documentId_email: {
|
||||||
documentId,
|
documentId,
|
||||||
email: field.signerEmail.toLowerCase(),
|
email: fieldSignerEmail,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (upsertedField.recipientId === null) {
|
||||||
|
throw new Error('Not possible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseAuditLog = {
|
||||||
|
fieldId: upsertedField.secondaryId,
|
||||||
|
fieldRecipientEmail: fieldSignerEmail,
|
||||||
|
fieldRecipientId: upsertedField.recipientId,
|
||||||
|
fieldType: upsertedField.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
const changes = field._persisted ? diffFieldChanges(field._persisted, upsertedField) : [];
|
||||||
|
|
||||||
|
// Handle field updated audit log.
|
||||||
|
if (field._persisted && changes.length > 0) {
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
|
||||||
|
documentId: documentId,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
changes,
|
||||||
|
...baseAuditLog,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle field created audit log.
|
||||||
|
if (!field._persisted) {
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
|
||||||
|
documentId: documentId,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
...baseAuditLog,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return upsertedField;
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (removedFields.length > 0) {
|
if (removedFields.length > 0) {
|
||||||
await prisma.field.deleteMany({
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.field.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
in: removedFields.map((field) => field.id),
|
in: removedFields.map((field) => field.id),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.createMany({
|
||||||
|
data: removedFields.map((field) =>
|
||||||
|
createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED,
|
||||||
|
documentId: documentId,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
fieldId: field.secondaryId,
|
||||||
|
fieldRecipientEmail: field.Recipient?.email ?? '',
|
||||||
|
fieldRecipientId: field.recipientId ?? -1,
|
||||||
|
fieldType: field.type,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedFields;
|
return persistedFields;
|
||||||
|
|||||||
@@ -27,8 +27,21 @@ export const setFieldsForTemplate = async ({
|
|||||||
const template = await prisma.template.findFirst({
|
const template = await prisma.template.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: templateId,
|
id: templateId,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
|
|
||||||
export type SignFieldWithTokenOptions = {
|
export type SignFieldWithTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
fieldId: number;
|
fieldId: number;
|
||||||
value: string;
|
value: string;
|
||||||
isBase64?: boolean;
|
isBase64?: boolean;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signFieldWithToken = async ({
|
export const signFieldWithToken = async ({
|
||||||
@@ -20,6 +25,7 @@ export const signFieldWithToken = async ({
|
|||||||
fieldId,
|
fieldId,
|
||||||
value,
|
value,
|
||||||
isBase64,
|
isBase64,
|
||||||
|
requestMetadata,
|
||||||
}: SignFieldWithTokenOptions) => {
|
}: SignFieldWithTokenOptions) => {
|
||||||
const field = await prisma.field.findFirstOrThrow({
|
const field = await prisma.field.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -40,6 +46,10 @@ export const signFieldWithToken = async ({
|
|||||||
throw new Error(`Document not found for field ${field.id}`);
|
throw new Error(`Document not found for field ${field.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Error(`Recipient not found for field ${field.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
throw new Error(`Document ${document.id} has already been completed`);
|
throw new Error(`Document ${document.id} has already been completed`);
|
||||||
}
|
}
|
||||||
@@ -123,6 +133,38 @@ export const signFieldWithToken = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||||
|
documentId: document.id,
|
||||||
|
user: {
|
||||||
|
email: recipient.email,
|
||||||
|
name: recipient.name,
|
||||||
|
},
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientRole: recipient.role,
|
||||||
|
fieldId: updatedField.secondaryId,
|
||||||
|
field: match(updatedField.type)
|
||||||
|
.with(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE, (type) => ({
|
||||||
|
type,
|
||||||
|
data: signatureImageAsBase64 || typedSignature || '',
|
||||||
|
}))
|
||||||
|
.with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, FieldType.TEXT, (type) => ({
|
||||||
|
type,
|
||||||
|
data: updatedField.customText,
|
||||||
|
}))
|
||||||
|
.exhaustive(),
|
||||||
|
fieldSecurity: {
|
||||||
|
type: 'NONE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
return updatedField;
|
return updatedField;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,21 @@ export const getRecipientsForTemplate = async ({
|
|||||||
where: {
|
where: {
|
||||||
templateId,
|
templateId,
|
||||||
Template: {
|
Template: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
id: 'asc',
|
id: 'asc',
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import {
|
||||||
|
createDocumentAuditLogData,
|
||||||
|
diffRecipientChanges,
|
||||||
|
} from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { RecipientRole } from '@documenso/prisma/client';
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { nanoid } from '../../universal/id';
|
|
||||||
|
|
||||||
export interface SetRecipientsForDocumentOptions {
|
export interface SetRecipientsForDocumentOptions {
|
||||||
userId: number;
|
userId: number;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@@ -13,12 +18,14 @@ export interface SetRecipientsForDocumentOptions {
|
|||||||
name: string;
|
name: string;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
}[];
|
}[];
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setRecipientsForDocument = async ({
|
export const setRecipientsForDocument = async ({
|
||||||
userId,
|
userId,
|
||||||
documentId,
|
documentId,
|
||||||
recipients,
|
recipients,
|
||||||
|
requestMetadata,
|
||||||
}: SetRecipientsForDocumentOptions) => {
|
}: SetRecipientsForDocumentOptions) => {
|
||||||
const document = await prisma.document.findFirst({
|
const document = await prisma.document.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -40,10 +47,25 @@ export const setRecipientsForDocument = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
throw new Error('Document not found');
|
throw new Error('Document not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.completedAt) {
|
||||||
|
throw new Error('Document already complete');
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedRecipients = recipients.map((recipient) => ({
|
const normalizedRecipients = recipients.map((recipient) => ({
|
||||||
...recipient,
|
...recipient,
|
||||||
email: recipient.email.toLowerCase(),
|
email: recipient.email.toLowerCase(),
|
||||||
@@ -77,16 +99,16 @@ export const setRecipientsForDocument = async ({
|
|||||||
})
|
})
|
||||||
.filter((recipient) => {
|
.filter((recipient) => {
|
||||||
return (
|
return (
|
||||||
recipient._persisted?.sendStatus !== SendStatus.SENT &&
|
recipient._persisted?.role === RecipientRole.CC ||
|
||||||
recipient._persisted?.signingStatus !== SigningStatus.SIGNED
|
(recipient._persisted?.sendStatus !== SendStatus.SENT &&
|
||||||
|
recipient._persisted?.signingStatus !== SigningStatus.SIGNED)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistedRecipients = await prisma.$transaction(
|
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
||||||
// Disabling as wrapping promises here causes type issues
|
await Promise.all(
|
||||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
linkedRecipients.map(async (recipient) => {
|
||||||
linkedRecipients.map((recipient) =>
|
const upsertedRecipient = await tx.recipient.upsert({
|
||||||
prisma.recipient.upsert({
|
|
||||||
where: {
|
where: {
|
||||||
id: recipient._persisted?.id ?? -1,
|
id: recipient._persisted?.id ?? -1,
|
||||||
documentId,
|
documentId,
|
||||||
@@ -96,6 +118,7 @@ export const setRecipientsForDocument = async ({
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
documentId,
|
documentId,
|
||||||
|
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||||
signingStatus:
|
signingStatus:
|
||||||
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
},
|
},
|
||||||
@@ -109,18 +132,95 @@ export const setRecipientsForDocument = async ({
|
|||||||
signingStatus:
|
signingStatus:
|
||||||
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientId = upsertedRecipient.id;
|
||||||
|
|
||||||
|
// Clear all fields if the recipient role is changed to a type that cannot have fields.
|
||||||
|
if (
|
||||||
|
recipient._persisted &&
|
||||||
|
recipient._persisted.role !== recipient.role &&
|
||||||
|
(recipient.role === RecipientRole.CC || recipient.role === RecipientRole.VIEWER)
|
||||||
|
) {
|
||||||
|
await tx.field.deleteMany({
|
||||||
|
where: {
|
||||||
|
recipientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseAuditLog = {
|
||||||
|
recipientEmail: upsertedRecipient.email,
|
||||||
|
recipientName: upsertedRecipient.name,
|
||||||
|
recipientId,
|
||||||
|
recipientRole: upsertedRecipient.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
const changes = recipient._persisted
|
||||||
|
? diffRecipientChanges(recipient._persisted, upsertedRecipient)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Handle recipient updated audit log.
|
||||||
|
if (recipient._persisted && changes.length > 0) {
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||||
|
documentId: documentId,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
changes,
|
||||||
|
...baseAuditLog,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle recipient created audit log.
|
||||||
|
if (!recipient._persisted) {
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
|
||||||
|
documentId: documentId,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: baseAuditLog,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return upsertedRecipient;
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (removedRecipients.length > 0) {
|
if (removedRecipients.length > 0) {
|
||||||
await prisma.recipient.deleteMany({
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.recipient.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
in: removedRecipients.map((recipient) => recipient.id),
|
in: removedRecipients.map((recipient) => recipient.id),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.createMany({
|
||||||
|
data: removedRecipients.map((recipient) =>
|
||||||
|
createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
|
||||||
|
documentId: documentId,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
recipientRole: recipient.role,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedRecipients;
|
return persistedRecipients;
|
||||||
|
|||||||
@@ -20,8 +20,21 @@ export const setRecipientsForTemplate = async ({
|
|||||||
const template = await prisma.template.findFirst({
|
const template = await prisma.template.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: templateId,
|
id: templateId,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
|
|||||||
@@ -72,8 +72,20 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
|
|||||||
where: whereFilter,
|
where: whereFilter,
|
||||||
include: {
|
include: {
|
||||||
teamEmail: true,
|
teamEmail: true,
|
||||||
emailVerification: true,
|
emailVerification: {
|
||||||
transferVerification: true,
|
select: {
|
||||||
|
expiresAt: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transferVerification: {
|
||||||
|
select: {
|
||||||
|
expiresAt: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
subscription: true,
|
subscription: true,
|
||||||
members: {
|
members: {
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -11,7 +11,23 @@ export const createDocumentFromTemplate = async ({
|
|||||||
userId,
|
userId,
|
||||||
}: CreateDocumentFromTemplateOptions) => {
|
}: CreateDocumentFromTemplateOptions) => {
|
||||||
const template = await prisma.template.findUnique({
|
const template = await prisma.template.findUnique({
|
||||||
where: { id: templateId, userId },
|
where: {
|
||||||
|
id: templateId,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
Recipient: true,
|
Recipient: true,
|
||||||
Field: true,
|
Field: true,
|
||||||
@@ -34,6 +50,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
const document = await prisma.document.create({
|
const document = await prisma.document.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
|
teamId: template.teamId,
|
||||||
title: template.title,
|
title: template.title,
|
||||||
documentDataId: documentData.id,
|
documentDataId: documentData.id,
|
||||||
Recipient: {
|
Recipient: {
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
|
import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
|
||||||
|
|
||||||
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
|
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
teamId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTemplate = async ({
|
export const createTemplate = async ({
|
||||||
title,
|
title,
|
||||||
userId,
|
userId,
|
||||||
|
teamId,
|
||||||
templateDocumentDataId,
|
templateDocumentDataId,
|
||||||
}: CreateTemplateOptions) => {
|
}: CreateTemplateOptions) => {
|
||||||
|
if (teamId) {
|
||||||
|
await prisma.team.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: teamId,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return await prisma.template.create({
|
return await prisma.template.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
userId,
|
userId,
|
||||||
templateDocumentDataId,
|
templateDocumentDataId,
|
||||||
|
teamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,5 +8,23 @@ export type DeleteTemplateOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => {
|
export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => {
|
||||||
return await prisma.template.delete({ where: { id, userId } });
|
return await prisma.template.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,39 @@
|
|||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { Prisma } from '@documenso/prisma/client';
|
||||||
import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
|
import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
|
||||||
|
|
||||||
export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
|
export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
|
||||||
userId: number;
|
userId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplateOptions) => {
|
export const duplicateTemplate = async ({
|
||||||
|
templateId,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
}: DuplicateTemplateOptions) => {
|
||||||
|
let templateWhereFilter: Prisma.TemplateWhereUniqueInput = {
|
||||||
|
id: templateId,
|
||||||
|
userId,
|
||||||
|
teamId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (teamId !== undefined) {
|
||||||
|
templateWhereFilter = {
|
||||||
|
id: templateId,
|
||||||
|
teamId,
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const template = await prisma.template.findUnique({
|
const template = await prisma.template.findUnique({
|
||||||
where: { id: templateId, userId },
|
where: templateWhereFilter,
|
||||||
include: {
|
include: {
|
||||||
Recipient: true,
|
Recipient: true,
|
||||||
Field: true,
|
Field: true,
|
||||||
@@ -31,6 +56,7 @@ export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplat
|
|||||||
const duplicatedTemplate = await prisma.template.create({
|
const duplicatedTemplate = await prisma.template.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
|
teamId,
|
||||||
title: template.title + ' (copy)',
|
title: template.title + ' (copy)',
|
||||||
templateDocumentDataId: documentData.id,
|
templateDocumentDataId: documentData.id,
|
||||||
Recipient: {
|
Recipient: {
|
||||||
|
|||||||
56
packages/lib/server-only/template/find-templates.ts
Normal file
56
packages/lib/server-only/template/find-templates.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { Prisma } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type FindTemplatesOptions = {
|
||||||
|
userId: number;
|
||||||
|
teamId?: number;
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findTemplates = async ({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
page = 1,
|
||||||
|
perPage = 10,
|
||||||
|
}: FindTemplatesOptions) => {
|
||||||
|
let whereFilter: Prisma.TemplateWhereInput = {
|
||||||
|
userId,
|
||||||
|
teamId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (teamId !== undefined) {
|
||||||
|
whereFilter = {
|
||||||
|
team: {
|
||||||
|
id: teamId,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [templates, count] = await Promise.all([
|
||||||
|
prisma.template.findMany({
|
||||||
|
where: whereFilter,
|
||||||
|
include: {
|
||||||
|
templateDocumentData: true,
|
||||||
|
Field: true,
|
||||||
|
},
|
||||||
|
skip: Math.max(page - 1, 0) * perPage,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.template.count({
|
||||||
|
where: whereFilter,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
totalPages: Math.ceil(count / perPage),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { Prisma } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export interface GetTemplateByIdOptions {
|
export interface GetTemplateByIdOptions {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -6,11 +7,26 @@ export interface GetTemplateByIdOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => {
|
export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => {
|
||||||
return await prisma.template.findFirstOrThrow({
|
const whereFilter: Prisma.TemplateWhereInput = {
|
||||||
where: {
|
|
||||||
id,
|
id,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return await prisma.template.findFirstOrThrow({
|
||||||
|
where: whereFilter,
|
||||||
include: {
|
include: {
|
||||||
templateDocumentData: true,
|
templateDocumentData: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
export type GetTemplatesOptions = {
|
|
||||||
userId: number;
|
|
||||||
page: number;
|
|
||||||
perPage: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTemplates = async ({ userId, page = 1, perPage = 10 }: GetTemplatesOptions) => {
|
|
||||||
const [templates, count] = await Promise.all([
|
|
||||||
prisma.template.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
templateDocumentData: true,
|
|
||||||
Field: true,
|
|
||||||
},
|
|
||||||
skip: Math.max(page - 1, 0) * perPage,
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.template.count({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
templates,
|
|
||||||
totalPages: Math.ceil(count / perPage),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetMostRecentVerificationTokenByUserIdOptions = {
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMostRecentVerificationTokenByUserId = async ({
|
||||||
|
userId,
|
||||||
|
}: GetMostRecentVerificationTokenByUserIdOptions) => {
|
||||||
|
return await prisma.verificationToken.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { ONE_HOUR } from '../../constants/time';
|
import { ONE_HOUR } from '../../constants/time';
|
||||||
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||||
|
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
|
||||||
|
|
||||||
const IDENTIFIER = 'confirmation-email';
|
const IDENTIFIER = 'confirmation-email';
|
||||||
|
|
||||||
export const sendConfirmationToken = async ({ email }: { email: string }) => {
|
type SendConfirmationTokenOptions = { email: string; force?: boolean };
|
||||||
|
|
||||||
|
export const sendConfirmationToken = async ({
|
||||||
|
email,
|
||||||
|
force = false,
|
||||||
|
}: SendConfirmationTokenOptions) => {
|
||||||
const token = crypto.randomBytes(20).toString('hex');
|
const token = crypto.randomBytes(20).toString('hex');
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
@@ -20,6 +27,21 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
|
|||||||
throw new Error('User not found');
|
throw new Error('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.emailVerified) {
|
||||||
|
throw new Error('Email verified');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mostRecentToken = await getMostRecentVerificationTokenByUserId({ userId: user.id });
|
||||||
|
|
||||||
|
// If we've sent a token in the last 5 minutes, don't send another one
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
mostRecentToken?.createdAt &&
|
||||||
|
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const createdToken = await prisma.verificationToken.create({
|
const createdToken = await prisma.verificationToken.create({
|
||||||
data: {
|
data: {
|
||||||
identifier: IDENTIFIER,
|
identifier: IDENTIFIER,
|
||||||
@@ -37,5 +59,11 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
|
|||||||
throw new Error(`Failed to create the verification token`);
|
throw new Error(`Failed to create the verification token`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendConfirmationEmail({ userId: user.id });
|
try {
|
||||||
|
await sendConfirmationEmail({ userId: user.id });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to send the confirmation email`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
350
packages/lib/types/document-audit-logs.ts
Normal file
350
packages/lib/types/document-audit-logs.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
//
|
||||||
|
// Be aware that any changes to this file may require migrations since we are storing JSON
|
||||||
|
// data in Prisma.
|
||||||
|
//
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||||
|
// Document actions.
|
||||||
|
'EMAIL_SENT',
|
||||||
|
|
||||||
|
// Document modification events.
|
||||||
|
'FIELD_CREATED',
|
||||||
|
'FIELD_DELETED',
|
||||||
|
'FIELD_UPDATED',
|
||||||
|
'RECIPIENT_CREATED',
|
||||||
|
'RECIPIENT_DELETED',
|
||||||
|
'RECIPIENT_UPDATED',
|
||||||
|
|
||||||
|
// Document events.
|
||||||
|
'DOCUMENT_COMPLETED',
|
||||||
|
'DOCUMENT_CREATED',
|
||||||
|
'DOCUMENT_DELETED',
|
||||||
|
'DOCUMENT_FIELD_INSERTED',
|
||||||
|
'DOCUMENT_FIELD_UNINSERTED',
|
||||||
|
'DOCUMENT_META_UPDATED',
|
||||||
|
'DOCUMENT_OPENED',
|
||||||
|
'DOCUMENT_TITLE_UPDATED',
|
||||||
|
'DOCUMENT_RECIPIENT_COMPLETED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ZDocumentMetaDiffTypeSchema = z.enum([
|
||||||
|
'DATE_FORMAT',
|
||||||
|
'MESSAGE',
|
||||||
|
'PASSWORD',
|
||||||
|
'REDIRECT_URL',
|
||||||
|
'SUBJECT',
|
||||||
|
'TIMEZONE',
|
||||||
|
]);
|
||||||
|
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
|
||||||
|
export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']);
|
||||||
|
|
||||||
|
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
|
||||||
|
export const DOCUMENT_META_DIFF_TYPE = ZDocumentMetaDiffTypeSchema.Enum;
|
||||||
|
export const FIELD_DIFF_TYPE = ZFieldDiffTypeSchema.Enum;
|
||||||
|
export const RECIPIENT_DIFF_TYPE = ZRecipientDiffTypeSchema.Enum;
|
||||||
|
|
||||||
|
export const ZFieldDiffDimensionSchema = z.object({
|
||||||
|
type: z.literal(FIELD_DIFF_TYPE.DIMENSION),
|
||||||
|
from: z.object({
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
}),
|
||||||
|
to: z.object({
|
||||||
|
width: z.number(),
|
||||||
|
height: z.number(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZFieldDiffPositionSchema = z.object({
|
||||||
|
type: z.literal(FIELD_DIFF_TYPE.POSITION),
|
||||||
|
from: z.object({
|
||||||
|
page: z.number(),
|
||||||
|
positionX: z.number(),
|
||||||
|
positionY: z.number(),
|
||||||
|
}),
|
||||||
|
to: z.object({
|
||||||
|
page: z.number(),
|
||||||
|
positionX: z.number(),
|
||||||
|
positionY: z.number(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZDocumentAuditLogDocumentMetaSchema = z.union([
|
||||||
|
z.object({
|
||||||
|
type: z.union([
|
||||||
|
z.literal(DOCUMENT_META_DIFF_TYPE.DATE_FORMAT),
|
||||||
|
z.literal(DOCUMENT_META_DIFF_TYPE.MESSAGE),
|
||||||
|
z.literal(DOCUMENT_META_DIFF_TYPE.REDIRECT_URL),
|
||||||
|
z.literal(DOCUMENT_META_DIFF_TYPE.SUBJECT),
|
||||||
|
z.literal(DOCUMENT_META_DIFF_TYPE.TIMEZONE),
|
||||||
|
]),
|
||||||
|
from: z.string().nullable(),
|
||||||
|
to: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal(DOCUMENT_META_DIFF_TYPE.PASSWORD),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ZDocumentAuditLogFieldDiffSchema = z.union([
|
||||||
|
ZFieldDiffDimensionSchema,
|
||||||
|
ZFieldDiffPositionSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ZRecipientDiffNameSchema = z.object({
|
||||||
|
type: z.literal(RECIPIENT_DIFF_TYPE.NAME),
|
||||||
|
from: z.string(),
|
||||||
|
to: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZRecipientDiffRoleSchema = z.object({
|
||||||
|
type: z.literal(RECIPIENT_DIFF_TYPE.ROLE),
|
||||||
|
from: z.string(),
|
||||||
|
to: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZRecipientDiffEmailSchema = z.object({
|
||||||
|
type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL),
|
||||||
|
from: z.string(),
|
||||||
|
to: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZDocumentAuditLogRecipientDiffSchema = z.union([
|
||||||
|
ZRecipientDiffNameSchema,
|
||||||
|
ZRecipientDiffRoleSchema,
|
||||||
|
ZRecipientDiffEmailSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ZBaseFieldEventDataSchema = z.object({
|
||||||
|
fieldId: z.string(), // Note: This is the secondary field ID, which will get migrated in the future.
|
||||||
|
fieldRecipientEmail: z.string(),
|
||||||
|
fieldRecipientId: z.number(),
|
||||||
|
fieldType: z.string(), // We specifically don't want to use enums to allow for more flexibility.
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZBaseRecipientDataSchema = z.object({
|
||||||
|
recipientEmail: z.string(),
|
||||||
|
recipientName: z.string(),
|
||||||
|
recipientId: z.number(),
|
||||||
|
recipientRole: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Email sent.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventEmailSentSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT),
|
||||||
|
data: ZBaseRecipientDataSchema.extend({
|
||||||
|
emailType: z.enum([
|
||||||
|
'SIGNING_REQUEST',
|
||||||
|
'VIEW_REQUEST',
|
||||||
|
'APPROVE_REQUEST',
|
||||||
|
'CC',
|
||||||
|
'DOCUMENT_COMPLETED',
|
||||||
|
]),
|
||||||
|
isResending: z.boolean(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document completed.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentCompletedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED),
|
||||||
|
data: z.object({
|
||||||
|
transactionId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document created.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED),
|
||||||
|
data: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document field inserted.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED),
|
||||||
|
data: ZBaseRecipientDataSchema.extend({
|
||||||
|
fieldId: z.string(),
|
||||||
|
|
||||||
|
// Organised into union to allow us to extend each field if required.
|
||||||
|
field: z.union([
|
||||||
|
z.object({
|
||||||
|
type: z.literal(FieldType.EMAIL),
|
||||||
|
data: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal(FieldType.DATE),
|
||||||
|
data: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal(FieldType.NAME),
|
||||||
|
data: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal(FieldType.TEXT),
|
||||||
|
data: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
|
||||||
|
data: z.string(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Todo: Replace with union once we have more field security types.
|
||||||
|
fieldSecurity: z.object({
|
||||||
|
type: z.literal('NONE'),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document field uninserted.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED),
|
||||||
|
data: z.object({
|
||||||
|
field: z.nativeEnum(FieldType),
|
||||||
|
fieldId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document meta updated.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentMetaUpdatedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED),
|
||||||
|
data: z.object({
|
||||||
|
changes: z.array(ZDocumentAuditLogDocumentMetaSchema),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document opened.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentOpenedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED),
|
||||||
|
data: ZBaseRecipientDataSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document).
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED),
|
||||||
|
data: ZBaseRecipientDataSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Document title updated.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventDocumentTitleUpdatedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED),
|
||||||
|
data: z.object({
|
||||||
|
from: z.string(),
|
||||||
|
to: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Field created.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventFieldCreatedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED),
|
||||||
|
data: ZBaseFieldEventDataSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Field deleted.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventFieldRemovedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED),
|
||||||
|
data: ZBaseFieldEventDataSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Field updated.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventFieldUpdatedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED),
|
||||||
|
data: ZBaseFieldEventDataSchema.extend({
|
||||||
|
changes: z.array(ZDocumentAuditLogFieldDiffSchema),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Recipient added.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventRecipientAddedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED),
|
||||||
|
data: ZBaseRecipientDataSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Recipient updated.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventRecipientUpdatedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED),
|
||||||
|
data: ZBaseRecipientDataSchema.extend({
|
||||||
|
changes: z.array(ZDocumentAuditLogRecipientDiffSchema),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event: Recipient deleted.
|
||||||
|
*/
|
||||||
|
export const ZDocumentAuditLogEventRecipientRemovedSchema = z.object({
|
||||||
|
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED),
|
||||||
|
data: ZBaseRecipientDataSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
documentId: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||||
|
z.union([
|
||||||
|
ZDocumentAuditLogEventEmailSentSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentCompletedSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentCreatedSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentOpenedSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
||||||
|
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
||||||
|
ZDocumentAuditLogEventFieldCreatedSchema,
|
||||||
|
ZDocumentAuditLogEventFieldRemovedSchema,
|
||||||
|
ZDocumentAuditLogEventFieldUpdatedSchema,
|
||||||
|
ZDocumentAuditLogEventRecipientAddedSchema,
|
||||||
|
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||||
|
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TDocumentAuditLog = z.infer<typeof ZDocumentAuditLogSchema>;
|
||||||
|
export type TDocumentAuditLogType = z.infer<typeof ZDocumentAuditLogTypeSchema>;
|
||||||
|
|
||||||
|
export type TDocumentAuditLogFieldDiffSchema = z.infer<typeof ZDocumentAuditLogFieldDiffSchema>;
|
||||||
|
|
||||||
|
export type TDocumentAuditLogDocumentMetaDiffSchema = z.infer<
|
||||||
|
typeof ZDocumentAuditLogDocumentMetaSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type TDocumentAuditLogRecipientDiffSchema = z.infer<
|
||||||
|
typeof ZDocumentAuditLogRecipientDiffSchema
|
||||||
|
>;
|
||||||
@@ -25,10 +25,16 @@ export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetad
|
|||||||
export const extractNextAuthRequestMetadata = (
|
export const extractNextAuthRequestMetadata = (
|
||||||
req: Pick<RequestInternal, 'body' | 'query' | 'headers' | 'method'>,
|
req: Pick<RequestInternal, 'body' | 'query' | 'headers' | 'method'>,
|
||||||
): RequestMetadata => {
|
): RequestMetadata => {
|
||||||
const parsedIp = ZIpSchema.safeParse(req.headers?.['x-forwarded-for']);
|
return extractNextHeaderRequestMetadata(req.headers ?? {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractNextHeaderRequestMetadata = (
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): RequestMetadata => {
|
||||||
|
const parsedIp = ZIpSchema.safeParse(headers?.['x-forwarded-for']);
|
||||||
|
|
||||||
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
|
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
|
||||||
const userAgent = req.headers?.['user-agent'];
|
const userAgent = headers?.['user-agent'];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ipAddress,
|
ipAddress,
|
||||||
|
|||||||
205
packages/lib/utils/document-audit-logs.ts
Normal file
205
packages/lib/utils/document-audit-logs.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TDocumentAuditLog,
|
||||||
|
TDocumentAuditLogDocumentMetaDiffSchema,
|
||||||
|
TDocumentAuditLogFieldDiffSchema,
|
||||||
|
TDocumentAuditLogRecipientDiffSchema,
|
||||||
|
} from '../types/document-audit-logs';
|
||||||
|
import {
|
||||||
|
DOCUMENT_META_DIFF_TYPE,
|
||||||
|
FIELD_DIFF_TYPE,
|
||||||
|
RECIPIENT_DIFF_TYPE,
|
||||||
|
ZDocumentAuditLogSchema,
|
||||||
|
} from '../types/document-audit-logs';
|
||||||
|
import type { RequestMetadata } from '../universal/extract-request-metadata';
|
||||||
|
|
||||||
|
type CreateDocumentAuditLogDataOptions<T = TDocumentAuditLog['type']> = {
|
||||||
|
documentId: number;
|
||||||
|
type: T;
|
||||||
|
data: Extract<TDocumentAuditLog, { type: T }>['data'];
|
||||||
|
user: { email?: string; id?: number | null; name?: string | null } | null;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateDocumentAuditLogDataResponse = Pick<
|
||||||
|
DocumentAuditLog,
|
||||||
|
'type' | 'ipAddress' | 'userAgent' | 'email' | 'userId' | 'name' | 'documentId'
|
||||||
|
> & {
|
||||||
|
data: TDocumentAuditLog['data'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDocumentAuditLogData = ({
|
||||||
|
documentId,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
}: CreateDocumentAuditLogDataOptions): CreateDocumentAuditLogDataResponse => {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
documentId,
|
||||||
|
userId: user?.id ?? null,
|
||||||
|
email: user?.email ?? null,
|
||||||
|
name: user?.name ?? null,
|
||||||
|
userAgent: requestMetadata?.userAgent ?? null,
|
||||||
|
ipAddress: requestMetadata?.ipAddress ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a raw document audit log from Prisma, to a typed audit log.
|
||||||
|
*
|
||||||
|
* @param auditLog raw audit log from Prisma.
|
||||||
|
*/
|
||||||
|
export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocumentAuditLog => {
|
||||||
|
const data = ZDocumentAuditLogSchema.safeParse(auditLog);
|
||||||
|
|
||||||
|
// Handle any required migrations here.
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('Migration required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role'>;
|
||||||
|
|
||||||
|
export const diffRecipientChanges = (
|
||||||
|
oldRecipient: PartialRecipient,
|
||||||
|
newRecipient: PartialRecipient,
|
||||||
|
): TDocumentAuditLogRecipientDiffSchema[] => {
|
||||||
|
const diffs: TDocumentAuditLogRecipientDiffSchema[] = [];
|
||||||
|
|
||||||
|
if (oldRecipient.email !== newRecipient.email) {
|
||||||
|
diffs.push({
|
||||||
|
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
||||||
|
from: oldRecipient.email,
|
||||||
|
to: newRecipient.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldRecipient.role !== newRecipient.role) {
|
||||||
|
diffs.push({
|
||||||
|
type: RECIPIENT_DIFF_TYPE.ROLE,
|
||||||
|
from: oldRecipient.role,
|
||||||
|
to: newRecipient.role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldRecipient.name !== newRecipient.name) {
|
||||||
|
diffs.push({
|
||||||
|
type: RECIPIENT_DIFF_TYPE.NAME,
|
||||||
|
from: oldRecipient.name,
|
||||||
|
to: newRecipient.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const diffFieldChanges = (
|
||||||
|
oldField: Field,
|
||||||
|
newField: Field,
|
||||||
|
): TDocumentAuditLogFieldDiffSchema[] => {
|
||||||
|
const diffs: TDocumentAuditLogFieldDiffSchema[] = [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
oldField.page !== newField.page ||
|
||||||
|
!oldField.positionX.equals(newField.positionX) ||
|
||||||
|
!oldField.positionY.equals(newField.positionY)
|
||||||
|
) {
|
||||||
|
diffs.push({
|
||||||
|
type: FIELD_DIFF_TYPE.POSITION,
|
||||||
|
from: {
|
||||||
|
page: oldField.page,
|
||||||
|
positionX: oldField.positionX.toNumber(),
|
||||||
|
positionY: oldField.positionY.toNumber(),
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
page: newField.page,
|
||||||
|
positionX: newField.positionX.toNumber(),
|
||||||
|
positionY: newField.positionY.toNumber(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldField.width.equals(newField.width) || !oldField.height.equals(newField.height)) {
|
||||||
|
diffs.push({
|
||||||
|
type: FIELD_DIFF_TYPE.DIMENSION,
|
||||||
|
from: {
|
||||||
|
width: oldField.width.toNumber(),
|
||||||
|
height: oldField.height.toNumber(),
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
width: newField.width.toNumber(),
|
||||||
|
height: newField.height.toNumber(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const diffDocumentMetaChanges = (
|
||||||
|
oldData: Partial<DocumentMeta> = {},
|
||||||
|
newData: DocumentMeta,
|
||||||
|
): TDocumentAuditLogDocumentMetaDiffSchema[] => {
|
||||||
|
const diffs: TDocumentAuditLogDocumentMetaDiffSchema[] = [];
|
||||||
|
|
||||||
|
const oldDateFormat = oldData?.dateFormat ?? '';
|
||||||
|
const oldMessage = oldData?.message ?? '';
|
||||||
|
const oldSubject = oldData?.subject ?? '';
|
||||||
|
const oldTimezone = oldData?.timezone ?? '';
|
||||||
|
const oldPassword = oldData?.password ?? null;
|
||||||
|
const oldRedirectUrl = oldData?.redirectUrl ?? '';
|
||||||
|
|
||||||
|
if (oldDateFormat !== newData.dateFormat) {
|
||||||
|
diffs.push({
|
||||||
|
type: DOCUMENT_META_DIFF_TYPE.DATE_FORMAT,
|
||||||
|
from: oldData?.dateFormat ?? '',
|
||||||
|
to: newData.dateFormat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldMessage !== newData.message) {
|
||||||
|
diffs.push({
|
||||||
|
type: DOCUMENT_META_DIFF_TYPE.MESSAGE,
|
||||||
|
from: oldMessage,
|
||||||
|
to: newData.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldSubject !== newData.subject) {
|
||||||
|
diffs.push({
|
||||||
|
type: DOCUMENT_META_DIFF_TYPE.SUBJECT,
|
||||||
|
from: oldSubject,
|
||||||
|
to: newData.subject,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldTimezone !== newData.timezone) {
|
||||||
|
diffs.push({
|
||||||
|
type: DOCUMENT_META_DIFF_TYPE.TIMEZONE,
|
||||||
|
from: oldTimezone,
|
||||||
|
to: newData.timezone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldRedirectUrl !== newData.redirectUrl) {
|
||||||
|
diffs.push({
|
||||||
|
type: DOCUMENT_META_DIFF_TYPE.REDIRECT_URL,
|
||||||
|
from: oldRedirectUrl,
|
||||||
|
to: newData.redirectUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldPassword !== newData.password) {
|
||||||
|
diffs.push({
|
||||||
|
type: DOCUMENT_META_DIFF_TYPE.PASSWORD,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffs;
|
||||||
|
};
|
||||||
@@ -12,6 +12,10 @@ export const formatDocumentsPath = (teamUrl?: string) => {
|
|||||||
return teamUrl ? `/t/${teamUrl}/documents` : '/documents';
|
return teamUrl ? `/t/${teamUrl}/documents` : '/documents';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatTemplatesPath = (teamUrl?: string) => {
|
||||||
|
return teamUrl ? `/t/${teamUrl}/templates` : '/templates';
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether a team member can execute a given action.
|
* Determines whether a team member can execute a given action.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Template" ADD COLUMN "teamId" INTEGER;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Template" ADD CONSTRAINT "Template_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentMeta" ADD COLUMN "redirectUrl" TEXT;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[secondaryId]` on the table `Field` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- The required column `secondaryId` was added to the `Field` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Field" ADD COLUMN "secondaryId" TEXT;
|
||||||
|
|
||||||
|
-- Set all null secondaryId fields to a uuid
|
||||||
|
UPDATE "Field" SET "secondaryId" = gen_random_uuid()::text WHERE "secondaryId" IS NULL;
|
||||||
|
|
||||||
|
-- Restrict the Field to required
|
||||||
|
ALTER TABLE "Field" ALTER COLUMN "secondaryId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DocumentAuditLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"documentId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"data" JSONB NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"userId" INTEGER,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "DocumentAuditLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Field_secondaryId_key" ON "Field"("secondaryId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DocumentAuditLog" ADD CONSTRAINT "DocumentAuditLog_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -170,11 +170,30 @@ model Document {
|
|||||||
teamId Int?
|
teamId Int?
|
||||||
team Team? @relation(fields: [teamId], references: [id])
|
team Team? @relation(fields: [teamId], references: [id])
|
||||||
|
|
||||||
|
auditLogs DocumentAuditLog[]
|
||||||
|
|
||||||
@@unique([documentDataId])
|
@@unique([documentDataId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DocumentAuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
documentId Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
type String
|
||||||
|
data Json
|
||||||
|
|
||||||
|
// Details of the person who performed the action which caused the audit log.
|
||||||
|
name String?
|
||||||
|
email String?
|
||||||
|
userId Int?
|
||||||
|
userAgent String?
|
||||||
|
ipAddress String?
|
||||||
|
|
||||||
|
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
enum DocumentDataType {
|
enum DocumentDataType {
|
||||||
S3_PATH
|
S3_PATH
|
||||||
BYTES
|
BYTES
|
||||||
@@ -199,6 +218,7 @@ model DocumentMeta {
|
|||||||
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||||
documentId Int @unique
|
documentId Int @unique
|
||||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
|
redirectUrl String?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ReadStatus {
|
enum ReadStatus {
|
||||||
@@ -259,6 +279,7 @@ enum FieldType {
|
|||||||
|
|
||||||
model Field {
|
model Field {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
secondaryId String @unique @default(cuid())
|
||||||
documentId Int?
|
documentId Int?
|
||||||
templateId Int?
|
templateId Int?
|
||||||
recipientId Int?
|
recipientId Int?
|
||||||
@@ -335,6 +356,7 @@ model Team {
|
|||||||
subscription Subscription?
|
subscription Subscription?
|
||||||
|
|
||||||
document Document[]
|
document Document[]
|
||||||
|
templates Template[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model TeamPending {
|
model TeamPending {
|
||||||
@@ -415,10 +437,12 @@ model Template {
|
|||||||
type TemplateType @default(PRIVATE)
|
type TemplateType @default(PRIVATE)
|
||||||
title String
|
title String
|
||||||
userId Int
|
userId Int
|
||||||
|
teamId Int?
|
||||||
templateDocumentDataId String
|
templateDocumentDataId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
|
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
|
||||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
Recipient Recipient[]
|
Recipient Recipient[]
|
||||||
|
|||||||
36
packages/prisma/seed/templates.ts
Normal file
36
packages/prisma/seed/templates.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { prisma } from '..';
|
||||||
|
import { DocumentDataType } from '../client';
|
||||||
|
|
||||||
|
const examplePdf = fs
|
||||||
|
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
|
||||||
|
.toString('base64');
|
||||||
|
|
||||||
|
type SeedTemplateOptions = {
|
||||||
|
title?: string;
|
||||||
|
userId: number;
|
||||||
|
teamId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seedTemplate = async (options: SeedTemplateOptions) => {
|
||||||
|
const { title = 'Untitled', userId, teamId } = options;
|
||||||
|
|
||||||
|
const documentData = await prisma.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: DocumentDataType.BYTES_64,
|
||||||
|
data: examplePdf,
|
||||||
|
initialData: examplePdf,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await prisma.template.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
templateDocumentDataId: documentData.id,
|
||||||
|
userId: userId,
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import { updateTitle } from '@documenso/lib/server-only/document/update-title';
|
|||||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||||
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@@ -88,6 +89,7 @@ export const documentRouter = router({
|
|||||||
teamId,
|
teamId,
|
||||||
title,
|
title,
|
||||||
documentDataId,
|
documentDataId,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TRPCError) {
|
if (err instanceof TRPCError) {
|
||||||
@@ -131,6 +133,7 @@ export const documentRouter = router({
|
|||||||
title,
|
title,
|
||||||
userId,
|
userId,
|
||||||
documentId,
|
documentId,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -144,6 +147,7 @@ export const documentRouter = router({
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
documentId,
|
documentId,
|
||||||
recipients,
|
recipients,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -166,6 +170,7 @@ export const documentRouter = router({
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
documentId,
|
documentId,
|
||||||
fields,
|
fields,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -198,6 +203,7 @@ export const documentRouter = router({
|
|||||||
documentId,
|
documentId,
|
||||||
password: securePassword,
|
password: securePassword,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -215,20 +221,23 @@ export const documentRouter = router({
|
|||||||
try {
|
try {
|
||||||
const { documentId, meta } = input;
|
const { documentId, meta } = input;
|
||||||
|
|
||||||
if (meta.message || meta.subject || meta.timezone || meta.dateFormat) {
|
if (meta.message || meta.subject || meta.timezone || meta.dateFormat || meta.redirectUrl) {
|
||||||
await upsertDocumentMeta({
|
await upsertDocumentMeta({
|
||||||
documentId,
|
documentId,
|
||||||
subject: meta.subject,
|
subject: meta.subject,
|
||||||
message: meta.message,
|
message: meta.message,
|
||||||
dateFormat: meta.dateFormat,
|
dateFormat: meta.dateFormat,
|
||||||
timezone: meta.timezone,
|
timezone: meta.timezone,
|
||||||
|
redirectUrl: meta.redirectUrl,
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await sendDocument({
|
return await sendDocument({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
documentId,
|
documentId,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -247,6 +256,7 @@ export const documentRouter = router({
|
|||||||
return await resendDocument({
|
return await resendDocument({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
...input,
|
...input,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
|
||||||
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const ZGetDocumentByIdQuerySchema = z.object({
|
export const ZGetDocumentByIdQuerySchema = z.object({
|
||||||
@@ -73,6 +74,12 @@ export const ZSendDocumentMutationSchema = z.object({
|
|||||||
message: z.string(),
|
message: z.string(),
|
||||||
timezone: z.string(),
|
timezone: z.string(),
|
||||||
dateFormat: z.string(),
|
dateFormat: z.string(),
|
||||||
|
redirectUrl: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
|
||||||
|
message: 'Please enter a valid URL',
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/rem
|
|||||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||||
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
|
||||||
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
|
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
|
||||||
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@@ -33,13 +34,14 @@ export const fieldRouter = router({
|
|||||||
pageWidth: field.pageWidth,
|
pageWidth: field.pageWidth,
|
||||||
pageHeight: field.pageHeight,
|
pageHeight: field.pageHeight,
|
||||||
})),
|
})),
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'We were unable to sign this field. Please try again later.',
|
message: 'We were unable to set this field. Please try again later.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -67,7 +69,7 @@ export const fieldRouter = router({
|
|||||||
|
|
||||||
signFieldWithToken: procedure
|
signFieldWithToken: procedure
|
||||||
.input(ZSignFieldWithTokenMutationSchema)
|
.input(ZSignFieldWithTokenMutationSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { token, fieldId, value, isBase64 } = input;
|
const { token, fieldId, value, isBase64 } = input;
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ export const fieldRouter = router({
|
|||||||
fieldId,
|
fieldId,
|
||||||
value,
|
value,
|
||||||
isBase64,
|
isBase64,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -89,13 +92,14 @@ export const fieldRouter = router({
|
|||||||
|
|
||||||
removeSignedFieldWithToken: procedure
|
removeSignedFieldWithToken: procedure
|
||||||
.input(ZRemovedSignedFieldWithTokenMutationSchema)
|
.input(ZRemovedSignedFieldWithTokenMutationSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { token, fieldId } = input;
|
const { token, fieldId } = input;
|
||||||
|
|
||||||
return await removeSignedFieldWithToken({
|
return await removeSignedFieldWithToken({
|
||||||
token,
|
token,
|
||||||
fieldId,
|
fieldId,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export const profileRouter = router({
|
|||||||
try {
|
try {
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
return sendConfirmationToken({ email });
|
return await sendConfirmationToken({ email });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let message = 'We were unable to send a confirmation email. Please try again.';
|
let message = 'We were unable to send a confirmation email. Please try again.';
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template';
|
import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template';
|
||||||
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@@ -27,13 +28,14 @@ export const recipientRouter = router({
|
|||||||
name: signer.name,
|
name: signer.name,
|
||||||
role: signer.role,
|
role: signer.role,
|
||||||
})),
|
})),
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'We were unable to sign this field. Please try again later.',
|
message: 'We were unable to set this field. Please try again later.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -58,20 +60,21 @@ export const recipientRouter = router({
|
|||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'We were unable to sign this field. Please try again later.',
|
message: 'We were unable to set this field. Please try again later.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
completeDocumentWithToken: procedure
|
completeDocumentWithToken: procedure
|
||||||
.input(ZCompleteDocumentWithTokenMutationSchema)
|
.input(ZCompleteDocumentWithTokenMutationSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { token, documentId } = input;
|
const { token, documentId } = input;
|
||||||
|
|
||||||
return await completeDocumentWithToken({
|
return await completeDocumentWithToken({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const singleplayerRouter = router({
|
|||||||
: null,
|
: null,
|
||||||
// Dummy data.
|
// Dummy data.
|
||||||
id: -1,
|
id: -1,
|
||||||
|
secondaryId: '-1',
|
||||||
documentId: -1,
|
documentId: -1,
|
||||||
templateId: null,
|
templateId: null,
|
||||||
recipientId: -1,
|
recipientId: -1,
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ export const templateRouter = router({
|
|||||||
.input(ZCreateTemplateMutationSchema)
|
.input(ZCreateTemplateMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { title, templateDocumentDataId } = input;
|
const { teamId, title, templateDocumentDataId } = input;
|
||||||
|
|
||||||
return await createTemplate({
|
return await createTemplate({
|
||||||
title,
|
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
teamId,
|
||||||
|
title,
|
||||||
templateDocumentDataId,
|
templateDocumentDataId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -64,11 +65,12 @@ export const templateRouter = router({
|
|||||||
.input(ZDuplicateTemplateMutationSchema)
|
.input(ZDuplicateTemplateMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { templateId } = input;
|
const { teamId, templateId } = input;
|
||||||
|
|
||||||
return await duplicateTemplate({
|
return await duplicateTemplate({
|
||||||
templateId,
|
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
teamId,
|
||||||
|
templateId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -88,7 +90,7 @@ export const templateRouter = router({
|
|||||||
|
|
||||||
const userId = ctx.user.id;
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
return await deleteTemplate({ id, userId });
|
return await deleteTemplate({ userId, id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const ZCreateTemplateMutationSchema = z.object({
|
export const ZCreateTemplateMutationSchema = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1).trim(),
|
||||||
|
teamId: z.number().optional(),
|
||||||
templateDocumentDataId: z.string().min(1),
|
templateDocumentDataId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
|
|
||||||
export const ZDuplicateTemplateMutationSchema = z.object({
|
export const ZDuplicateTemplateMutationSchema = z.object({
|
||||||
templateId: z.number(),
|
templateId: z.number(),
|
||||||
|
teamId: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZDeleteTemplateMutationSchema = z.object({
|
export const ZDeleteTemplateMutationSchema = z.object({
|
||||||
|
|||||||
@@ -304,6 +304,13 @@ export const AddFieldsFormPartial = ({
|
|||||||
return recipientsByRole;
|
return recipientsByRole;
|
||||||
}, [recipients]);
|
}, [recipients]);
|
||||||
|
|
||||||
|
const recipientsByRoleToDisplay = useMemo(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter(
|
||||||
|
([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER,
|
||||||
|
);
|
||||||
|
}, [recipientsByRole]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocumentFlowFormContainerHeader
|
<DocumentFlowFormContainerHeader
|
||||||
@@ -382,13 +389,10 @@ export const AddFieldsFormPartial = ({
|
|||||||
</span>
|
</span>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
|
|
||||||
{Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => (
|
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
|
||||||
<CommandGroup key={roleIndex}>
|
<CommandGroup key={roleIndex}>
|
||||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||||
{
|
{`${RECIPIENT_ROLES_DESCRIPTION[role].roleName}s`}
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recipients.length === 0 && (
|
{recipients.length === 0 && (
|
||||||
@@ -403,7 +407,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
{recipients.map((recipient) => (
|
{recipients.map((recipient) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={recipient.id}
|
key={recipient.id}
|
||||||
className={cn('px-4 last:mb-1 [&:not(:first-child)]:mt-1', {
|
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1', {
|
||||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||||
})}
|
})}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -413,7 +417,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn('text-foreground/70 truncate', {
|
className={cn('text-foreground/70 truncate', {
|
||||||
'text-foreground': recipient === selectedSigner,
|
'text-foreground/80': recipient === selectedSigner,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{recipient.name && (
|
{recipient.name && (
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ export const AddSignersFormPartial = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return recipients.some(
|
return recipients.some(
|
||||||
(recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT,
|
(recipient) =>
|
||||||
|
recipient.id === id &&
|
||||||
|
recipient.sendStatus === SendStatus.SENT &&
|
||||||
|
recipient.role !== RecipientRole.CC,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, 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';
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@documenso/ui/primitives/select';
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
import { Combobox } from '../combobox';
|
import { Combobox } from '../combobox';
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
@@ -30,7 +33,7 @@ import { Input } from '../input';
|
|||||||
import { Label } from '../label';
|
import { Label } from '../label';
|
||||||
import { useStep } from '../stepper';
|
import { useStep } from '../stepper';
|
||||||
import { Textarea } from '../textarea';
|
import { Textarea } from '../textarea';
|
||||||
import type { TAddSubjectFormSchema } from './add-subject.types';
|
import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types';
|
||||||
import {
|
import {
|
||||||
DocumentFlowFormContainerActions,
|
DocumentFlowFormContainerActions,
|
||||||
DocumentFlowFormContainerContent,
|
DocumentFlowFormContainerContent,
|
||||||
@@ -69,8 +72,10 @@ export const AddSubjectFormPartial = ({
|
|||||||
message: document.documentMeta?.message ?? '',
|
message: document.documentMeta?.message ?? '',
|
||||||
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||||
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
redirectUrl: document.documentMeta?.redirectUrl ?? '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resolver: zodResolver(ZAddSubjectFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmit = handleSubmit(onSubmit);
|
const onFormSubmit = handleSubmit(onSubmit);
|
||||||
@@ -163,15 +168,16 @@ export const AddSubjectFormPartial = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasDateField && (
|
|
||||||
<Accordion type="multiple" className="mt-8 border-none">
|
<Accordion type="multiple" className="mt-8 border-none">
|
||||||
<AccordionItem value="advanced-options" className="border-none">
|
<AccordionItem value="advanced-options" className="border-none">
|
||||||
<AccordionTrigger className="mb-2 border-b text-left hover:no-underline">
|
<AccordionTrigger className="mb-2 border-b text-left hover:no-underline">
|
||||||
Advanced Options
|
Advanced Options
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|
||||||
<AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 text-sm leading-relaxed">
|
<AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 pt-2 text-sm leading-relaxed">
|
||||||
<div className="mt-2 flex flex-col">
|
{hasDateField && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col">
|
||||||
<Label htmlFor="date-format">
|
<Label htmlFor="date-format">
|
||||||
Date Format <span className="text-muted-foreground">(Optional)</span>
|
Date Format <span className="text-muted-foreground">(Optional)</span>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -217,10 +223,39 @@ export const AddSubjectFormPartial = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="redirectUrl" className="flex items-center">
|
||||||
|
Redirect URL{' '}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Info className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||||
|
Add a URL to redirect the user to once the document is signed
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="redirectUrl"
|
||||||
|
type="url"
|
||||||
|
className="bg-background my-2"
|
||||||
|
{...register('meta.redirectUrl')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-2" error={errors.meta?.redirectUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DocumentFlowFormContainerContent>
|
</DocumentFlowFormContainerContent>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
|
||||||
|
|
||||||
export const ZAddSubjectFormSchema = z.object({
|
export const ZAddSubjectFormSchema = z.object({
|
||||||
meta: z.object({
|
meta: z.object({
|
||||||
@@ -9,6 +10,12 @@ export const ZAddSubjectFormSchema = z.object({
|
|||||||
message: z.string(),
|
message: z.string(),
|
||||||
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
|
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
|
||||||
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
|
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
|
||||||
|
redirectUrl: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
|
||||||
|
message: 'Please enter a valid URL',
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user