Compare commits

..
Author SHA1 Message Date
Timur Ercan 5362fad01a chore: typo 2024-02-26 12:23:46 +01:00
Timur Ercan 9319a89056 chore: text 2024-02-26 12:14:05 +01:00
Timur Ercan 2f8a665feb chore: text 2024-02-26 10:48:03 +01:00
Timur Ercan 5a1e671882 chore: text 2024-02-26 10:47:47 +01:00
Timur Ercan 69d695972b chore: update video link 2024-02-26 10:42:42 +01:00
Timur Ercan f4575217c6 chore: typos 2024-02-23 12:32:13 +01:00
Timur Ercan 6422bca525 chore: typos 2024-02-22 19:51:24 +01:00
Timur Ercan a306bf3524 chore: typos and content day5 2024-02-22 16:03:37 +01:00
Timur Ercan 21056fb39c chore: launchweek content 2024-02-19 16:05:36 +01:00
Timur Ercan c7f109203d feat: launch week #2 teaser 2024-02-07 15:25:45 +01:00
Timur Ercan c451b2d871 fix: tags 2024-02-07 15:19:49 +01:00
Timur Ercan 63c4e0af80 fix: tags 2024-02-07 15:19:38 +01:00
101 changed files with 864 additions and 2625 deletions
@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-01-25
Tags:
tags:
- Vision
- Mission
- Open Source
@@ -0,0 +1,64 @@
---
title: Launch Week II - Day 1 - Teams
description: Teams for Documenso are here. And they come free for early adopters!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-26
tags:
- Launch Week
- teams
- early adopter perks
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/12a85ec7-20bb-4813-9714-e4da42c9cfba"
autoPlay
loop
muted
></video>
> TLDR; Docucmenso now supports teams that share documents, templates and a team mail address. Early Adopter get UNLIMITED<sup>1</sup> Users.
## Kicking off Launch Week II - "Connected"
The day has come! Roughly 5 months after kicked off our first launch week with open sourcing our design and Malfunction Mania, Launch Week #2 is here 🎉 This Launch Week's theme is "connected", since this is all about connecting humans, machines and documents.
Working with documents and getting that signature is a team sport. This is why we are kicking it off today with a very long-awaited feature: Documenso now supports teams!
## Introducing Teams for Documenso
You can now create teams next to your personal account: Simply invite your colleagues, and you can include everyone you like in working with your documents. With teams, you can:
- Send unlimited signature requests with unlimited recipients
- Create, view, edit and sign documents owned by the team
- Define a dedicated team email, to receive signing requests into a team inbox for the owner to sign
- Manage team roles: Member (Create+Edit), Manger Manage (+Manage Team Members), Owner (+Transfer Team +Delete Team + Sign Documents sent to team email)
## Pricing
Together with Teams, we are announcing the new teams pricing:
- $10 per seat per month
- 5 seats minimum
- You can add seats dynamically as needed
This pricing will take effect, as soon as the early adopter seats run out. Want to check out teams: [https://documen.so/teams](https://documen.so/teams).
## Early Adopter Perks
There is one more point on pricing I have been looking forward to for a long time:
All early adopter plans now include **UNLIMITED teams and users**<sup>1</sup> . We appreciate your support so far very much, and I'm happy to announce this first of more early adopter perks to come. We have roughly 48 early adopter plans left, so if you plan to onboard your team, now is a great time to [grab your early adopter seat.](https://documen.so/claim-early-adopters-plan)
We are eager to hear from all teams users how you like this addition and what we can add to make it even better. Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here, and we would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 1! Teams just dropped. Check it out https://documen.so/day1 🚀"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur
\
[1] Within reason. If you are unsure what that means, feel free to contact hi@documenso.com and ask for clarification if it's more than 100.
@@ -0,0 +1,51 @@
---
title: Launch Week II - Day 2 - Templates
description: Templates help you prepare regular documents faster. And you can share them with your team!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-27
tags:
- Launch Week
- secret
- no spoiler
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/c9504db1-26b7-4033-88ed-a95cabd02e92"
autoPlay
loop
muted
></video>
> TLDR; You can now reuse documents via templates. More field types coming soon as well.
## Introducing Templates
It's day 2 of Launch Week, everybody 🙌 After introducing [Teams](https://documenso.com/blog/launch-week-2-day-1) yesterday, today we are looking at making Documenso faster for daily use:
We are launching templates for Documenso! Templates are an easy to reuse documents you send out often with just a few clicks. With templates, you can:
- Save often-uploaded documents for reuse
- Define fields once and just fill them out before sending (if at all)
- Create and share templates with your team
## Pricing
Templates are **included in all Documenso Plans!** That includes our free tier: The limit of 5 documents per month still applies, but you are free to reach it with less friction using templates. Sharing templates with other users is only possible with the teams plan. If you want to share templates with people not in your team, we might have something coming up later this week 👀
## What's Next for Templates
While this is all great, we have a lot of great stuff coming up for templates:
- More Field Types
- Custom Field Types Defined by You
- Sharing Templates Externally 👀
Check out templates [here](https://documen.so/templates) and let us know what you think and what we can improve. Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 2! They just launched templates, and I'm pumped 🎉🚀🚀🚀. Check it out https://documen.so/day2"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur
@@ -0,0 +1,48 @@
---
title: Launch Week II - Day 3 - API
description: Documenso's mission is to create a plattform developers all around the world can build upon. Today we are releasing the first version of our public API, included in all plans!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-28
tags:
- Launch Week
- API
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/cb74d6cb-a127-4cac-a166-ad6b56c6140d"
autoPlay
loop
muted
></video>
> TLDR; The public API is now availible for all plans.
## Introducing the public Documenso API
Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform that developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. Since this is the first version, we focused on the basics. With the new API you can:
- Get Documents (Individual or all Accessible)
- Upload documents
- Delete Documents
- Trigger Sending Documents for Singing
You can check out the detailed API documentation here: TODO API DOCS URL
## Pricing
We are building Documenso to be an open and extendable platform; therefore, therefore the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs.
## What's next for the API
You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better?
Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 3! The public API is here 👀 Check it out https://documen.so/day3"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur
@@ -0,0 +1,48 @@
---
title: Launch Week II - Day 4 - Zapier
description: If you want to integrate Documenso without fiddling with the API, we got you as well. You can now integrate Documenso via Zapier, included in all plans!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-29
tags:
- Launch Week
- Zapier
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/3b60789d-8d27-4c66-ae5c-179e33c2e3e6"
autoPlay
loop
muted
></video>
> TLDR; The public API is now availible for all plans.
## Introducing the public Documenso API
Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform, developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. This since this is the first version we focused on the basics. The API is With the new API you can:
- Get Documents (Individual or all Accessible)
- Upload documents
- Delete Documents
- Trigger Sending Documents for Singing
You can check out the detailed API documentation here: TODO API DOCS URL
## Pricing
We are building Documenso to be an open and extendable platform; therefore, the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs.
## What's next for the API
You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better?
Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 3! The public API is here 👀 Check it out https://documen.so/day3"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur
@@ -0,0 +1,59 @@
---
title: Launch Week II - Day 5 - Documenso Profiles
description: Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles will launch as soon as they are shiny.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-03-01
tags:
- Launch Week
- Profiles
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/89643ae2-2aa9-484c-a522-a0e35097c469"
autoPlay
loop
muted
></video>
> TLDR; Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles launch as soon as they are shiny.
## Introducing Documenso Profile Links
Signing documents has always been between humans, and signing something together should be as frictionless as possible. It should also be async, so you don't force your counterpart to jog to their device to send something when you are ready. Today we are announcing the new Documenso Profiles:
<figure>
<MdxNextImage
src="/blog/profile.png"
width="650"
height="100"
alt="lorem"
/>
<figcaption className="text-center">
Async > Sync: Add public templates to your Documenso Link and let people sign whenever they are ready.
</figcaption>
</figure>
Documenso profiles work with your existing templates. You can just add them to your public profile to let everyone with your link sign them. With profiles, we want to bring back the human aspect of signing.
By making profiles public, you can always access what your counterparty offers and make them more visible in the process. Long-term, we plan to add more to profiles to help you ensure the person you are dealing with is who they claim to be. Documenso wants to be the trust layer of the internet, and we want to start at the very fundamental level: The individual transaction.
Profiles are our first step towards bringing more trust into everything, simply by making the use of signing more frictionless. As there is more and more content of questionable origin out there, we want to support you in making it clear what you send out and what not.
## Pricing and Claiming
Documenso profile username can be claimed starting today. Documenso profiles will launch as soon as we are happy with the details ✨
- Long usernames (6 characters or more) come free with every account, e.g. **documenso.com/u/timurercan**
- Short usernames (5 characters or fewer) or less require any paid account ([Early Adopter](https://documen.so/claim-early-adopters-plan), [Teams](https://documen.so/teams) or Enterprise): **e.g., documenso.com/u/timur**
You can claim your username here: [https://documen.so/claim](https://documen.so/claim)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 5! You can now claim your username for the upcoming profile links 😮 https://documen.so/day5"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur
+1 -1
View File
@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-01-10
Tags:
tags:
- GitHub
- Backlog
- Roadmap
@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-06
Tags:
tags:
- Founders
- Mission
- Open Source
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -85,7 +85,6 @@ export const SinglePlayerClient = () => {
setFields(
data.fields.map((field, i) => ({
id: i,
secondaryId: i.toString(),
documentId: -1,
templateId: null,
recipientId: -1,
@@ -25,7 +25,7 @@ export type DocumentPageViewProps = {
team?: Team;
};
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
export default async function DocumentPageView({ params, team }: DocumentPageViewProps) {
const { id } = params;
const documentId = Number(id);
@@ -128,4 +128,4 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
)}
</div>
);
};
}
@@ -151,7 +151,7 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
const { subject, message, timezone, dateFormat } = data.meta;
try {
await sendDocument({
@@ -159,9 +159,8 @@ export const EditDocumentForm = ({
meta: {
subject,
message,
dateFormat,
timezone,
redirectUrl,
dateFormat,
},
});
@@ -1,4 +1,4 @@
import { DocumentPageView } from './document-page-view';
import DocumentPageView from './document-page-view';
export type DocumentPageProps = {
params: {
@@ -114,7 +114,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
{recipient && recipient?.role !== RecipientRole.CC && (
{recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
@@ -33,7 +33,10 @@ export type DocumentsPageViewProps = {
team?: Team & { teamEmail?: TeamEmail | null };
};
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
export default async function DocumentsPageView({
searchParams = {},
team,
}: DocumentsPageViewProps) {
const { user } = await getRequiredServerComponentSession();
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
@@ -152,4 +155,4 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
</div>
</div>
);
};
}
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import type { DocumentsPageViewProps } from './documents-page-view';
import { DocumentsPageView } from './documents-page-view';
import DocumentsPageView from './documents-page-view';
export type DocumentsPageProps = {
searchParams?: DocumentsPageViewProps['searchParams'];
@@ -28,7 +28,6 @@ export type EditTemplateFormProps = {
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
templateRootPath: string;
};
type EditTemplateStep = 'signers' | 'fields';
@@ -41,7 +40,6 @@ export const EditTemplateForm = ({
fields,
user: _user,
documentData,
templateRootPath,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
@@ -100,7 +98,7 @@ export const EditTemplateForm = ({
duration: 5000,
});
router.push(templateRootPath);
router.push('/templates');
} catch (err) {
toast({
title: 'Error',
@@ -1,10 +1,81 @@
import React from 'react';
import type { TemplatePageViewProps } from './template-page-view';
import { TemplatePageView } from './template-page-view';
import Link from 'next/link';
import { redirect } from 'next/navigation';
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
import { ChevronLeft } from 'lucide-react';
export default function TemplatePage({ params }: TemplatePageProps) {
return <TemplatePageView params={params} />;
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 { 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>
);
}
@@ -1,86 +0,0 @@
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,15 +21,9 @@ import { DuplicateTemplateDialog } from './duplicate-template-dialog';
export type DataTableActionDropdownProps = {
row: Template;
templateRootPath: string;
teamId?: number;
};
export const DataTableActionDropdown = ({
row,
templateRootPath,
teamId,
}: DataTableActionDropdownProps) => {
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -40,7 +34,6 @@ export const DataTableActionDropdown = ({
}
const isOwner = row.userId === session.user.id;
const isTeamTemplate = row.teamId === teamId;
return (
<DropdownMenu>
@@ -51,25 +44,20 @@ export const DataTableActionDropdown = ({
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<Link href={`${templateRootPath}/${row.id}`}>
<DropdownMenuItem disabled={!isOwner} asChild>
<Link href={`/templates/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDuplicateDialogOpen(true)}
>
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}
>
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
@@ -77,7 +65,6 @@ export const DataTableActionDropdown = ({
<DuplicateTemplateDialog
id={row.id}
teamId={teamId}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>
@@ -28,9 +28,6 @@ type TemplatesDataTableProps = {
perPage: number;
page: number;
totalPages: number;
documentRootPath: string;
templateRootPath: string;
teamId?: number;
};
export const TemplatesDataTable = ({
@@ -38,9 +35,6 @@ export const TemplatesDataTable = ({
perPage,
page,
totalPages,
documentRootPath,
templateRootPath,
teamId,
}: TemplatesDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
@@ -76,7 +70,7 @@ export const TemplatesDataTable = ({
duration: 5000,
});
router.push(`${documentRootPath}/${id}`);
router.push(`/documents/${id}`);
} catch (err) {
toast({
title: 'Error',
@@ -137,12 +131,7 @@ export const TemplatesDataTable = ({
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
Use Template
</Button>
<DataTableActionDropdown
row={row.original}
teamId={teamId}
templateRootPath={templateRootPath}
/>
<DataTableActionDropdown row={row.original} />
</div>
);
},
@@ -35,15 +35,20 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
onOpenChange(false);
},
onError: () => {
});
const onDeleteTemplate = async () => {
try {
await deleteTemplate({ id });
} catch {
toast({
title: 'Something went wrong',
description: 'This template could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
},
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
@@ -58,18 +63,20 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
disabled={isLoading}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={async () => deleteTemplate({ id })}>
Delete
</Button>
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
Delete
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -14,14 +14,12 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateTemplateDialogProps = {
id: number;
teamId?: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DuplicateTemplateDialog = ({
id,
teamId,
open,
onOpenChange,
}: DuplicateTemplateDialogProps) => {
@@ -42,15 +40,22 @@ export const DuplicateTemplateDialog = ({
onOpenChange(false);
},
onError: () => {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
},
});
const onDuplicate = async () => {
try {
await duplicateTemplate({
templateId: id,
});
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
@@ -61,27 +66,20 @@ export const DuplicateTemplateDialog = ({
</DialogHeader>
<DialogFooter>
<Button
type="button"
disabled={isLoading}
variant="secondary"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="button"
loading={isLoading}
onClick={async () =>
duplicateTemplate({
templateId: id,
teamId,
})
}
>
Duplicate
</Button>
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
Duplicate
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -43,14 +43,8 @@ const ZCreateTemplateFormSchema = z.object({
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
type NewTemplateDialogProps = {
teamId?: number;
templateRootPath: string;
};
export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => {
export const NewTemplateDialog = () => {
const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast();
@@ -105,7 +99,6 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
});
const { id } = await createTemplate({
teamId,
title: values.name ? values.name : file.name,
templateDocumentDataId,
});
@@ -119,7 +112,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
setShowNewTemplateDialog(false);
router.push(`${templateRootPath}/${id}`);
void router.push(`/templates/${id}`);
} catch {
toast({
title: 'Something went wrong',
@@ -2,17 +2,57 @@ import React from 'react';
import type { Metadata } from 'next';
import { TemplatesPageView } from './templates-page-view';
import type { TemplatesPageViewProps } from './templates-page-view';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
import { TemplatesDataTable } from './data-table-templates';
import { EmptyTemplateState } from './empty-state';
import { NewTemplateDialog } from './new-template-dialog';
type TemplatesPageProps = {
searchParams?: TemplatesPageViewProps['searchParams'];
searchParams?: {
page?: number;
perPage?: number;
};
};
export const metadata: Metadata = {
title: 'Templates',
};
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
return <TemplatesPageView searchParams={searchParams} />;
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
const { user } = await getRequiredServerComponentSession();
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>
);
}
@@ -1,73 +0,0 @@
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,10 +26,9 @@ export type SigningFormProps = {
document: Document;
recipient: Recipient;
fields: Field[];
redirectUrl?: string | null;
};
export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => {
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
@@ -75,7 +74,7 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
timestamp: new Date().toISOString(),
});
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
router.push(`/sign/${recipient.token}/complete`);
};
return (
@@ -118,7 +118,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
<span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div>
<div className="py-4">
<Label htmlFor="signature">Full Name</Label>
<Input
@@ -1,4 +1,3 @@
import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
@@ -9,12 +8,12 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
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 { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-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 { 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 { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@@ -41,26 +40,24 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound();
}
const requestHeaders = Object.fromEntries(headers().entries());
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token, requestMetadata }).catch(() => null),
viewedDocument({ token }).catch(() => null),
]);
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
if (!document || !document.documentData || !recipient) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const { documentData, documentMeta } = document;
const { documentData } = document;
const { user } = await getServerComponentSession();
@@ -68,9 +65,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED
) {
documentMeta?.redirectUrl
? redirect(documentMeta.redirectUrl)
: redirect(`/sign/${token}/complete`);
redirect(`/sign/${token}/complete`);
}
if (documentMeta?.password) {
@@ -138,12 +133,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
/>
<SigningForm document={document} recipient={recipient} fields={fields} />
</div>
</div>
@@ -1,7 +1,7 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentPageView } from '~/app/(dashboard)/documents/[id]/document-page-view';
import DocumentPageComponent from '~/app/(dashboard)/documents/[id]/document-page-view';
export type DocumentPageProps = {
params: {
@@ -16,5 +16,5 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <DocumentPageView params={params} team={team} />;
return <DocumentPageComponent 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 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 = {
params: {
@@ -18,7 +18,7 @@ export type TeamTransferStatusProps = {
className?: string;
currentUserTeamRole: TeamMemberRole;
teamId: number;
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
transferVerification: TeamTransferVerification | null;
};
export const TeamTransferStatus = ({
@@ -1,22 +0,0 @@
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} />;
}
@@ -1,26 +0,0 @@
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} />;
}
@@ -1,27 +0,0 @@
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,22 +52,24 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
{...props}
>
<div className="flex items-baseline gap-x-6">
{navigationLinks.map(({ href, label }) => (
<Link
key={href}
href={`${rootHref}${href}`}
className={cn(
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
`${rootHref}${href}`,
),
},
)}
>
{label}
</Link>
))}
{navigationLinks
.filter(({ href }) => href !== '/templates' || rootHref === '') // Remove templates for team pages.
.map(({ href, label }) => (
<Link
key={href}
href={`${rootHref}${href}`}
className={cn(
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
`${rootHref}${href}`,
),
},
)}
>
{label}
</Link>
))}
</div>
<CommandMenu open={open} onOpenChange={setOpen} />
@@ -6,7 +6,7 @@ import { usePathname } from 'next/navigation';
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
import { signOut } from 'next-auth/react';
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
@@ -71,22 +71,6 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -116,7 +100,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
<DropdownMenuLabel>Personal</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={formatRedirectUrlOnSwitch()}>
<Link href="/">
<AvatarWithText
avatarFallback={formatAvatarFallback()}
primaryText={user.name}
@@ -168,7 +152,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
{teams.map((team) => (
<DropdownMenuItem asChild key={team.id}>
<Link href={formatRedirectUrlOnSwitch(team.url)}>
<Link href={`/t/${team.url}`}>
<AvatarWithText
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
@@ -42,7 +42,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
href: '/settings/profile',
text: 'Settings',
},
];
].filter(({ text, href }) => text !== 'Templates' || href === '/templates'); // Filter out templates for teams.
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
@@ -1,95 +0,0 @@
'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>
);
};
-16
View File
@@ -2,8 +2,6 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@@ -40,8 +38,6 @@ 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',
[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.UNVERIFIED_EMAIL]:
'This account has not been verified. Please verify your account before signing in.',
};
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
@@ -67,7 +63,6 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
const { toast } = useToast();
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
const router = useRouter();
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
@@ -135,17 +130,6 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
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({
variant: 'destructive',
title: 'Unable to sign in',
+4 -10
View File
@@ -1,7 +1,5 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@@ -57,7 +55,6 @@ export type SignUpFormProps = {
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
const { toast } = useToast();
const analytics = useAnalytics();
const router = useRouter();
const form = useForm<TSignUpFormSchema>({
values: {
@@ -77,13 +74,10 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
try {
await signup({ name, email, password, signature });
router.push(`/unverified-account`);
toast({
title: 'Registration Successful',
description:
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
duration: 5000,
await signIn('credentials', {
email,
password,
callbackUrl: SIGN_UP_REDIRECT_PATH,
});
analytics.capture('App: User Sign Up', {
+2 -11
View File
@@ -14503,7 +14503,6 @@
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz",
"integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==",
"peer": true,
"engines": {
"node": ">=6.0.0"
}
@@ -19496,14 +19495,14 @@
"@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6",
"nodemailer": "^6.9.9",
"nodemailer": "^6.9.3",
"react-email": "^1.9.5",
"resend": "^2.0.0"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.14",
"@types/nodemailer": "^6.4.8",
"tsup": "^7.1.0"
}
},
@@ -19521,14 +19520,6 @@
"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": {
"name": "@documenso/eslint-config",
"version": "0.0.0",
@@ -1,205 +0,0 @@
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);
});
+2 -2
View File
@@ -35,14 +35,14 @@
"@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6",
"nodemailer": "^6.9.9",
"nodemailer": "^6.9.3",
"react-email": "^1.9.5",
"resend": "^2.0.0"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.14",
"@types/nodemailer": "^6.4.8",
"tsup": "^7.1.0"
}
}
@@ -24,9 +24,3 @@ export const RECIPIENT_ROLES_DESCRIPTION: {
roleName: 'Viewer',
},
};
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
} as const;
-1
View File
@@ -1,7 +1,6 @@
import { TeamMemberRole } from '@documenso/prisma/client';
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> = {
ADMIN: 'Admin',
-2
View File
@@ -1,2 +0,0 @@
export const URL_REGEX =
/^(https?):\/\/(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i;
-18
View File
@@ -13,9 +13,7 @@ import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/cl
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
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 { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { ErrorCode } from './error-codes';
@@ -92,22 +90,6 @@ 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 {
id: Number(user.id),
email: user.email,
-1
View File
@@ -19,5 +19,4 @@ export const ErrorCode = {
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL',
} as const;
@@ -1,7 +1,7 @@
'use server';
import { prisma } from '@documenso/prisma';
import type { DocumentDataType } from '@documenso/prisma/client';
import { DocumentDataType } from '@documenso/prisma/client';
export type CreateDocumentDataOptions = {
type: DocumentDataType;
@@ -1,11 +1,5 @@
'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';
export type CreateDocumentMetaOptions = {
@@ -15,9 +9,7 @@ export type CreateDocumentMetaOptions = {
timezone?: string;
password?: string;
dateFormat?: string;
redirectUrl?: string;
userId: number;
requestMetadata: RequestMetadata;
};
export const upsertDocumentMeta = async ({
@@ -26,81 +18,47 @@ export const upsertDocumentMeta = async ({
timezone,
dateFormat,
documentId,
password,
userId,
redirectUrl,
requestMetadata,
password,
}: CreateDocumentMetaOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
const { documentMeta: originalDocumentMeta } = await prisma.document.findFirstOrThrow({
await prisma.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId: user.id,
userId,
},
{
team: {
members: {
some: {
userId: user.id,
userId,
},
},
},
},
],
},
include: {
documentMeta: true,
});
return await prisma.documentMeta.upsert({
where: {
documentId,
},
create: {
subject,
message,
dateFormat,
timezone,
password,
documentId,
},
update: {
subject,
message,
dateFormat,
password,
timezone,
},
});
return await prisma.$transaction(async (tx) => {
const upsertedDocumentMeta = await tx.documentMeta.upsert({
where: {
documentId,
},
create: {
subject,
message,
password,
dateFormat,
timezone,
documentId,
redirectUrl,
},
update: {
subject,
message,
password,
dateFormat,
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,8 +1,5 @@
'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 { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
@@ -12,13 +9,11 @@ import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
documentId: number;
requestMetadata?: RequestMetadata;
};
export const completeDocumentWithToken = async ({
token,
documentId,
requestMetadata,
}: CompleteDocumentWithTokenOptions) => {
'use server';
@@ -75,24 +70,6 @@ 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({
where: {
documentId: document.id,
@@ -122,6 +99,6 @@ export const completeDocumentWithToken = async ({
});
if (documents.count > 0) {
await sealDocument({ documentId: document.id, requestMetadata });
await sealDocument({ documentId: document.id });
}
};
@@ -1,9 +1,5 @@
'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';
export type CreateDocumentOptions = {
@@ -11,7 +7,6 @@ export type CreateDocumentOptions = {
userId: number;
teamId?: number;
documentDataId: string;
requestMetadata?: RequestMetadata;
};
export const createDocument = async ({
@@ -19,30 +14,22 @@ export const createDocument = async ({
title,
documentDataId,
teamId,
requestMetadata,
}: CreateDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
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 prisma.$transaction(async (tx) => {
const document = await tx.document.create({
if (teamId !== undefined) {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
return await tx.document.create({
data: {
title,
documentDataId,
@@ -50,19 +37,5 @@ export const createDocument = async ({
teamId,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
user,
requestMetadata,
data: {
title,
},
}),
});
return document;
});
};
@@ -39,7 +39,6 @@ export const duplicateDocumentById = async ({
dateFormat: true,
password: true,
timezone: true,
redirectUrl: true,
},
},
},
@@ -27,7 +27,6 @@ export const getDocumentAndSenderByToken = async ({
include: {
User: true,
documentData: true,
documentMeta: true,
},
});
@@ -4,18 +4,12 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
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 { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } 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';
export type ResendDocumentOptions = {
@@ -23,7 +17,6 @@ export type ResendDocumentOptions = {
userId: number;
recipients: number[];
teamId?: number;
requestMetadata: RequestMetadata;
};
export const resendDocument = async ({
@@ -31,7 +24,6 @@ export const resendDocument = async ({
userId,
recipients,
teamId,
requestMetadata,
}: ResendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@@ -84,8 +76,6 @@ export const resendDocument = async ({
return;
}
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const customEmailTemplate = {
@@ -109,39 +99,20 @@ export const resendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await prisma.$transaction(async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
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,
},
}),
});
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
@@ -5,13 +5,10 @@ import path from 'node:path';
import { PDFDocument } from 'pdf-lib';
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 { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
@@ -20,14 +17,9 @@ import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = {
documentId: number;
sendEmail?: boolean;
requestMetadata?: RequestMetadata;
};
export const sealDocument = async ({
documentId,
sendEmail = true,
requestMetadata,
}: SealDocumentOptions) => {
export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumentOptions) => {
'use server';
const document = await prisma.document.findFirstOrThrow({
@@ -108,30 +100,16 @@ export const sealDocument = async ({
});
}
await prisma.$transaction(async (tx) => {
await tx.documentData.update({
where: {
id: documentData.id,
},
data: {
data: newData,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
documentId: document.id,
requestMetadata,
user: null,
data: {
transactionId: nanoid(),
},
}),
});
await prisma.documentData.update({
where: {
id: documentData.id,
},
data: {
data: newData,
},
});
if (sendEmail) {
await sendCompletedEmail({ documentId, requestMetadata });
await sendCompletedEmail({ documentId });
}
};
@@ -5,17 +5,13 @@ import { render } from '@documenso/email/render';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
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 { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export interface SendDocumentOptions {
documentId: number;
requestMetadata?: RequestMetadata;
}
export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDocumentOptions) => {
export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => {
const document = await prisma.document.findUnique({
where: {
id: documentId,
@@ -48,43 +44,24 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`,
});
await prisma.$transaction(async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
content: Buffer.from(buffer),
},
from: {
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
attachments: [
{
filename: document.title,
content: Buffer.from(buffer),
},
],
});
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,37 +4,22 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
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 { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
export type SendDocumentOptions = {
documentId: number;
userId: number;
requestMetadata?: RequestMetadata;
};
export const sendDocument = async ({
documentId,
userId,
requestMetadata,
}: SendDocumentOptions) => {
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
const document = await prisma.document.findUnique({
@@ -81,8 +66,6 @@ export const sendDocument = async ({
return;
}
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const customEmailTemplate = {
@@ -106,48 +89,29 @@ export const sendDocument = async ({
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await prisma.$transaction(async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
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,
},
}),
});
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
}),
);
@@ -1,76 +1,34 @@
'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';
export type UpdateTitleOptions = {
userId: number;
documentId: number;
title: string;
requestMetadata?: RequestMetadata;
};
export const updateTitle = async ({
userId,
documentId,
title,
requestMetadata,
}: UpdateTitleOptions) => {
const user = await prisma.user.findFirstOrThrow({
export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOptions) => {
return await prisma.document.update({
where: {
id: userId,
},
});
return await prisma.$transaction(async (tx) => {
const document = await tx.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
],
},
});
if (document.title === title) {
return document;
}
const updatedDocument = await tx.document.update({
where: {
id: documentId,
},
data: {
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;
],
},
data: {
title,
},
});
};
@@ -1,15 +1,11 @@
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 { ReadStatus } from '@documenso/prisma/client';
export type ViewedDocumentOptions = {
token: string;
requestMetadata?: RequestMetadata;
};
export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentOptions) => {
export const viewedDocument = async ({ token }: ViewedDocumentOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
@@ -17,38 +13,16 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO
},
});
if (!recipient || !recipient.documentId) {
if (!recipient) {
return;
}
const { documentId } = recipient;
await prisma.$transaction(async (tx) => {
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
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,
},
}),
});
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
readStatus: ReadStatus.OPENED,
},
});
};
@@ -10,20 +10,7 @@ export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForT
where: {
templateId,
Template: {
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
userId,
},
},
orderBy: {
@@ -1,21 +1,16 @@
'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 { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
export type RemovedSignedFieldWithTokenOptions = {
token: string;
fieldId: number;
requestMetadata?: RequestMetadata;
};
export const removeSignedFieldWithToken = async ({
token,
fieldId,
requestMetadata,
}: RemovedSignedFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
where: {
@@ -49,8 +44,8 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`);
}
await prisma.$transaction(async (tx) => {
await tx.field.update({
await Promise.all([
prisma.field.update({
where: {
id: field.id,
},
@@ -58,28 +53,11 @@ export const removeSignedFieldWithToken = async ({
customText: '',
inserted: false,
},
});
await tx.signature.deleteMany({
}),
prisma.signature.deleteMany({
where: {
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,9 +1,3 @@
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 type { FieldType } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
@@ -21,14 +15,12 @@ export interface SetFieldsForDocumentOptions {
pageWidth: number;
pageHeight: number;
}[];
requestMetadata?: RequestMetadata;
}
export const setFieldsForDocument = async ({
userId,
documentId,
fields,
requestMetadata,
}: SetFieldsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
@@ -50,25 +42,10 @@ export const setFieldsForDocument = async ({
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.completedAt) {
throw new Error('Document already complete');
}
const existingFields = await prisma.field.findMany({
where: {
documentId,
@@ -98,123 +75,56 @@ export const setFieldsForDocument = async ({
);
});
const persistedFields = await prisma.$transaction(async (tx) => {
await Promise.all(
linkedFields.map(async (field) => {
const fieldSignerEmail = field.signerEmail.toLowerCase();
const upsertedField = await tx.field.upsert({
where: {
id: field._persisted?.id ?? -1,
documentId,
},
update: {
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
create: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: documentId,
},
},
Recipient: {
connect: {
documentId_email: {
documentId,
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) {
await prisma.$transaction(async (tx) => {
await tx.field.deleteMany({
const persistedFields = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedFields.map((field) =>
prisma.field.upsert({
where: {
id: {
in: removedFields.map((field) => field.id),
id: field._persisted?.id ?? -1,
documentId,
},
update: {
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
create: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: documentId,
},
},
Recipient: {
connect: {
documentId_email: {
documentId,
email: field.signerEmail.toLowerCase(),
},
},
},
},
});
}),
),
);
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,
},
}),
),
});
if (removedFields.length > 0) {
await prisma.field.deleteMany({
where: {
id: {
in: removedFields.map((field) => field.id),
},
},
});
}
@@ -27,20 +27,7 @@ export const setFieldsForTemplate = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
userId,
},
});
@@ -1,23 +1,18 @@
'use server';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
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 = {
token: string;
fieldId: number;
value: string;
isBase64?: boolean;
requestMetadata?: RequestMetadata;
};
export const signFieldWithToken = async ({
@@ -25,7 +20,6 @@ export const signFieldWithToken = async ({
fieldId,
value,
isBase64,
requestMetadata,
}: SignFieldWithTokenOptions) => {
const field = await prisma.field.findFirstOrThrow({
where: {
@@ -46,10 +40,6 @@ export const signFieldWithToken = async ({
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) {
throw new Error(`Document ${document.id} has already been completed`);
}
@@ -133,38 +123,6 @@ 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;
});
};
@@ -13,20 +13,7 @@ export const getRecipientsForTemplate = async ({
where: {
templateId,
Template: {
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
userId,
},
},
orderBy: {
@@ -1,14 +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 { nanoid } from '@documenso/lib/universal/id';
import {
createDocumentAuditLogData,
diffRecipientChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id';
export interface SetRecipientsForDocumentOptions {
userId: number;
documentId: number;
@@ -18,14 +13,12 @@ export interface SetRecipientsForDocumentOptions {
name: string;
role: RecipientRole;
}[];
requestMetadata?: RequestMetadata;
}
export const setRecipientsForDocument = async ({
userId,
documentId,
recipients,
requestMetadata,
}: SetRecipientsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
@@ -47,25 +40,10 @@ export const setRecipientsForDocument = async ({
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.completedAt) {
throw new Error('Document already complete');
}
const normalizedRecipients = recipients.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
@@ -99,127 +77,49 @@ export const setRecipientsForDocument = async ({
})
.filter((recipient) => {
return (
recipient._persisted?.role === RecipientRole.CC ||
(recipient._persisted?.sendStatus !== SendStatus.SENT &&
recipient._persisted?.signingStatus !== SigningStatus.SIGNED)
recipient._persisted?.sendStatus !== SendStatus.SENT &&
recipient._persisted?.signingStatus !== SigningStatus.SIGNED
);
});
const persistedRecipients = await prisma.$transaction(async (tx) => {
await Promise.all(
linkedRecipients.map(async (recipient) => {
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
documentId,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
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;
const persistedRecipients = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedRecipients.map((recipient) =>
prisma.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
documentId,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
documentId,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
}),
);
});
),
);
if (removedRecipients.length > 0) {
await prisma.$transaction(async (tx) => {
await tx.recipient.deleteMany({
where: {
id: {
in: removedRecipients.map((recipient) => recipient.id),
},
await prisma.recipient.deleteMany({
where: {
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,
},
}),
),
});
},
});
}
@@ -20,20 +20,7 @@ export const setRecipientsForTemplate = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
userId,
},
});
+2 -14
View File
@@ -72,20 +72,8 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
where: whereFilter,
include: {
teamEmail: true,
emailVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
transferVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
emailVerification: true,
transferVerification: true,
subscription: true,
members: {
where: {
@@ -11,23 +11,7 @@ export const createDocumentFromTemplate = async ({
userId,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
where: { id: templateId, userId },
include: {
Recipient: true,
Field: true,
@@ -50,7 +34,6 @@ export const createDocumentFromTemplate = async ({
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {
@@ -1,36 +1,20 @@
import { prisma } from '@documenso/prisma';
import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
import { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
userId: number;
teamId?: number;
};
export const createTemplate = async ({
title,
userId,
teamId,
templateDocumentDataId,
}: CreateTemplateOptions) => {
if (teamId) {
await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
return await prisma.template.create({
data: {
title,
userId,
templateDocumentDataId,
teamId,
},
});
};
@@ -8,23 +8,5 @@ export type DeleteTemplateOptions = {
};
export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => {
return await prisma.template.delete({
where: {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
return await prisma.template.delete({ where: { id, userId } });
};
@@ -1,39 +1,14 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
userId: number;
};
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,
},
},
},
};
}
export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplateOptions) => {
const template = await prisma.template.findUnique({
where: templateWhereFilter,
where: { id: templateId, userId },
include: {
Recipient: true,
Field: true,
@@ -56,7 +31,6 @@ export const duplicateTemplate = async ({
const duplicatedTemplate = await prisma.template.create({
data: {
userId,
teamId,
title: template.title + ' (copy)',
templateDocumentDataId: documentData.id,
Recipient: {
@@ -1,56 +0,0 @@
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,5 +1,4 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
export interface GetTemplateByIdOptions {
id: number;
@@ -7,26 +6,11 @@ export interface GetTemplateByIdOptions {
}
export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => {
const whereFilter: Prisma.TemplateWhereInput = {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
};
return await prisma.template.findFirstOrThrow({
where: whereFilter,
where: {
id,
userId,
},
include: {
templateDocumentData: true,
},
@@ -0,0 +1,35 @@
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),
};
};
@@ -1,18 +0,0 @@
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,20 +1,13 @@
import crypto from 'crypto';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
const IDENTIFIER = 'confirmation-email';
type SendConfirmationTokenOptions = { email: string; force?: boolean };
export const sendConfirmationToken = async ({
email,
force = false,
}: SendConfirmationTokenOptions) => {
export const sendConfirmationToken = async ({ email }: { email: string }) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
@@ -27,21 +20,6 @@ export const sendConfirmationToken = async ({
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({
data: {
identifier: IDENTIFIER,
@@ -59,11 +37,5 @@ export const sendConfirmationToken = async ({
throw new Error(`Failed to create the verification token`);
}
try {
await sendConfirmationEmail({ userId: user.id });
return { success: true };
} catch (err) {
throw new Error(`Failed to send the confirmation email`);
}
return sendConfirmationEmail({ userId: user.id });
};
-350
View File
@@ -1,350 +0,0 @@
/////////////////////////////////////////////////////////////////////////////////////////////
//
// 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,16 +25,10 @@ export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetad
export const extractNextAuthRequestMetadata = (
req: Pick<RequestInternal, 'body' | 'query' | 'headers' | 'method'>,
): RequestMetadata => {
return extractNextHeaderRequestMetadata(req.headers ?? {});
};
export const extractNextHeaderRequestMetadata = (
headers: Record<string, string>,
): RequestMetadata => {
const parsedIp = ZIpSchema.safeParse(headers?.['x-forwarded-for']);
const parsedIp = ZIpSchema.safeParse(req.headers?.['x-forwarded-for']);
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
const userAgent = headers?.['user-agent'];
const userAgent = req.headers?.['user-agent'];
return {
ipAddress,
-205
View File
@@ -1,205 +0,0 @@
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;
};
-4
View File
@@ -12,10 +12,6 @@ export const formatDocumentsPath = (teamUrl?: string) => {
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.
*
@@ -1,5 +0,0 @@
-- 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;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "redirectUrl" TEXT;
@@ -1,37 +0,0 @@
/*
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;
+1 -25
View File
@@ -170,30 +170,11 @@ model Document {
teamId Int?
team Team? @relation(fields: [teamId], references: [id])
auditLogs DocumentAuditLog[]
@@unique([documentDataId])
@@index([userId])
@@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 {
S3_PATH
BYTES
@@ -218,7 +199,6 @@ model DocumentMeta {
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
}
enum ReadStatus {
@@ -279,7 +259,6 @@ enum FieldType {
model Field {
id Int @id @default(autoincrement())
secondaryId String @unique @default(cuid())
documentId Int?
templateId Int?
recipientId Int?
@@ -355,8 +334,7 @@ model Team {
owner User @relation(fields: [ownerUserId], references: [id])
subscription Subscription?
document Document[]
templates Template[]
document Document[]
}
model TeamPending {
@@ -437,12 +415,10 @@ model Template {
type TemplateType @default(PRIVATE)
title String
userId Int
teamId Int?
templateDocumentDataId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Recipient Recipient[]
-36
View File
@@ -1,36 +0,0 @@
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,
},
});
};
+1 -11
View File
@@ -15,7 +15,6 @@ import { updateTitle } from '@documenso/lib/server-only/document/update-title';
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 { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
@@ -89,7 +88,6 @@ export const documentRouter = router({
teamId,
title,
documentDataId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
if (err instanceof TRPCError) {
@@ -133,7 +131,6 @@ export const documentRouter = router({
title,
userId,
documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
}),
@@ -147,7 +144,6 @@ export const documentRouter = router({
userId: ctx.user.id,
documentId,
recipients,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@@ -170,7 +166,6 @@ export const documentRouter = router({
userId: ctx.user.id,
documentId,
fields,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@@ -203,7 +198,6 @@ export const documentRouter = router({
documentId,
password: securePassword,
userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@@ -221,23 +215,20 @@ export const documentRouter = router({
try {
const { documentId, meta } = input;
if (meta.message || meta.subject || meta.timezone || meta.dateFormat || meta.redirectUrl) {
if (meta.message || meta.subject || meta.timezone || meta.dateFormat) {
await upsertDocumentMeta({
documentId,
subject: meta.subject,
message: meta.message,
dateFormat: meta.dateFormat,
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
}
return await sendDocument({
userId: ctx.user.id,
documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@@ -256,7 +247,6 @@ export const documentRouter = router({
return await resendDocument({
userId: ctx.user.id,
...input,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@@ -1,6 +1,5 @@
import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({
@@ -74,12 +73,6 @@ export const ZSendDocumentMutationSchema = z.object({
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
+3 -7
View File
@@ -4,7 +4,6 @@ import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/rem
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 { 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 {
@@ -34,14 +33,13 @@ export const fieldRouter = router({
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
})),
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to set this field. Please try again later.',
message: 'We were unable to sign this field. Please try again later.',
});
}
}),
@@ -69,7 +67,7 @@ export const fieldRouter = router({
signFieldWithToken: procedure
.input(ZSignFieldWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
try {
const { token, fieldId, value, isBase64 } = input;
@@ -78,7 +76,6 @@ export const fieldRouter = router({
fieldId,
value,
isBase64,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@@ -92,14 +89,13 @@ export const fieldRouter = router({
removeSignedFieldWithToken: procedure
.input(ZRemovedSignedFieldWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
try {
const { token, fieldId } = input;
return await removeSignedFieldWithToken({
token,
fieldId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@@ -141,7 +141,7 @@ export const profileRouter = router({
try {
const { email } = input;
return await sendConfirmationToken({ email });
return sendConfirmationToken({ email });
} catch (err) {
let message = 'We were unable to send a confirmation email. Please try again.';
@@ -3,7 +3,6 @@ import { TRPCError } from '@trpc/server';
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 { 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 {
@@ -28,14 +27,13 @@ export const recipientRouter = router({
name: signer.name,
role: signer.role,
})),
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to set this field. Please try again later.',
message: 'We were unable to sign this field. Please try again later.',
});
}
}),
@@ -60,21 +58,20 @@ export const recipientRouter = router({
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to set this field. Please try again later.',
message: 'We were unable to sign this field. Please try again later.',
});
}
}),
completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
try {
const { token, documentId } = input;
return await completeDocumentWithToken({
token,
documentId,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@@ -62,7 +62,6 @@ export const singleplayerRouter = router({
: null,
// Dummy data.
id: -1,
secondaryId: '-1',
documentId: -1,
templateId: null,
recipientId: -1,
@@ -19,12 +19,11 @@ export const templateRouter = router({
.input(ZCreateTemplateMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { teamId, title, templateDocumentDataId } = input;
const { title, templateDocumentDataId } = input;
return await createTemplate({
userId: ctx.user.id,
teamId,
title,
userId: ctx.user.id,
templateDocumentDataId,
});
} catch (err) {
@@ -65,12 +64,11 @@ export const templateRouter = router({
.input(ZDuplicateTemplateMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { teamId, templateId } = input;
const { templateId } = input;
return await duplicateTemplate({
userId: ctx.user.id,
teamId,
templateId,
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
@@ -90,7 +88,7 @@ export const templateRouter = router({
const userId = ctx.user.id;
return await deleteTemplate({ userId, id });
return await deleteTemplate({ id, userId });
} catch (err) {
console.error(err);
@@ -1,8 +1,7 @@
import { z } from 'zod';
export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1).trim(),
teamId: z.number().optional(),
title: z.string().min(1),
templateDocumentDataId: z.string().min(1),
});
@@ -12,7 +11,6 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
export const ZDuplicateTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().optional(),
});
export const ZDeleteTemplateMutationSchema = z.object({
@@ -304,13 +304,6 @@ export const AddFieldsFormPartial = ({
return recipientsByRole;
}, [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 (
<>
<DocumentFlowFormContainerHeader
@@ -389,10 +382,13 @@ export const AddFieldsFormPartial = ({
</span>
</CommandEmpty>
{recipientsByRoleToDisplay.map(([role, recipients], roleIndex) => (
{Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<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>
{recipients.length === 0 && (
@@ -407,7 +403,7 @@ export const AddFieldsFormPartial = ({
{recipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn('px-2 last:mb-1 [&:not(:first-child)]:mt-1', {
className={cn('px-4 last:mb-1 [&:not(:first-child)]:mt-1', {
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})}
onSelect={() => {
@@ -417,7 +413,7 @@ export const AddFieldsFormPartial = ({
>
<span
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient === selectedSigner,
'text-foreground': recipient === selectedSigner,
})}
>
{recipient.name && (
@@ -105,10 +105,7 @@ export const AddSignersFormPartial = ({
}
return recipients.some(
(recipient) =>
recipient.id === id &&
recipient.sendStatus === SendStatus.SENT &&
recipient.role !== RecipientRole.CC,
(recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT,
);
};
@@ -2,8 +2,6 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@@ -25,7 +23,6 @@ import {
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { Combobox } from '../combobox';
import { FormErrorMessage } from '../form/form-error-message';
@@ -33,7 +30,7 @@ import { Input } from '../input';
import { Label } from '../label';
import { useStep } from '../stepper';
import { Textarea } from '../textarea';
import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types';
import type { TAddSubjectFormSchema } from './add-subject.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
@@ -72,10 +69,8 @@ export const AddSubjectFormPartial = ({
message: document.documentMeta?.message ?? '',
timezone: document.documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: document.documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: document.documentMeta?.redirectUrl ?? '',
},
},
resolver: zodResolver(ZAddSubjectFormSchema),
});
const onFormSubmit = handleSubmit(onSubmit);
@@ -168,94 +163,64 @@ export const AddSubjectFormPartial = ({
</ul>
</div>
<Accordion type="multiple" className="mt-8 border-none">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="mb-2 border-b text-left hover:no-underline">
Advanced Options
</AccordionTrigger>
{hasDateField && (
<Accordion type="multiple" className="mt-8 border-none">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="mb-2 border-b text-left hover:no-underline">
Advanced Options
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 pt-2 text-sm leading-relaxed">
{hasDateField && (
<>
<div className="flex flex-col">
<Label htmlFor="date-format">
Date Format <span className="text-muted-foreground">(Optional)</span>
</Label>
<AccordionContent className="text-muted-foreground -mx-1 flex max-w-prose flex-col px-1 text-sm leading-relaxed">
<div className="mt-2 flex flex-col">
<Label htmlFor="date-format">
Date Format <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.dateFormat`}
disabled={documentHasBeenSent}
render={({ field: { value, onChange, disabled } }) => (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="bg-background mt-2">
<SelectValue />
</SelectTrigger>
<Controller
control={control}
name={`meta.dateFormat`}
disabled={documentHasBeenSent}
render={({ field: { value, onChange, disabled } }) => (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="bg-background mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="mt-4 flex flex-col">
<Label htmlFor="time-zone">
Time Zone <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.timezone`}
render={({ field: { value, onChange } }) => (
<Combobox
className="bg-background"
options={TIME_ZONES}
value={value}
onChange={(value) => value && onChange(value)}
disabled={documentHasBeenSent}
/>
)}
/>
</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>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-4 flex flex-col">
<Label htmlFor="time-zone">
Time Zone <span className="text-muted-foreground">(Optional)</span>
</Label>
<Controller
control={control}
name={`meta.timezone`}
render={({ field: { value, onChange } }) => (
<Combobox
className="bg-background"
options={TIME_ZONES}
value={value}
onChange={(value) => value && onChange(value)}
disabled={documentHasBeenSent}
/>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
</div>
</DocumentFlowFormContainerContent>

Some files were not shown because too many files have changed in this diff Show More