mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
26 Commits
v1.6.0-rc.
...
v1.6.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ee732a9b | |||
| c3035dbd15 | |||
| 7f5b27372f | |||
| b0c081683f | |||
| 6b5e4da424 | |||
| cb892bcbb2 | |||
| a757ab2303 | |||
| 2c320e8b92 | |||
| 2eee2b4d2a | |||
| 06b1d4835e | |||
| 5f2dc1fe31 | |||
| b3cb9a10be | |||
| 7cff035f8a | |||
| 7ac899eb8d | |||
| 92c09c5850 | |||
| 90c43dcd0a | |||
| 48bf57d3aa | |||
| fc0c0a9754 | |||
| 455c3a63f9 | |||
| 780e91b055 | |||
| 611e495e16 | |||
| 6361dd5fe5 | |||
| e2674456d4 | |||
| b8cc2a2e0f | |||
| 68f7a7f090 | |||
| aa5beafe59 |
@ -78,6 +78,8 @@ NEXT_PRIVATE_SMTP_APIKEY_USER=
|
||||
NEXT_PRIVATE_SMTP_APIKEY=
|
||||
# OPTIONAL: Defines whether to force the use of TLS.
|
||||
NEXT_PRIVATE_SMTP_SECURE=
|
||||
# OPTIONAL: if this is true and NEXT_PRIVATE_SMTP_SECURE is false then TLS is not used even if the server supports STARTTLS extension
|
||||
NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS=
|
||||
# REQUIRED: Defines the sender name to use for the from address.
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
|
||||
# REQUIRED: Defines the email address to use as the from address.
|
||||
|
||||
68
apps/marketing/content/blog/announcing-profiles.mdx
Normal file
68
apps/marketing/content/blog/announcing-profiles.mdx
Normal file
@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Documenso Profiles Are Here
|
||||
description: Today, we are launching Documenso Profiles, a new way to let your peers sign your documents.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-07-01
|
||||
tags:
|
||||
- Announcement
|
||||
- Direct Links
|
||||
- Profiles
|
||||
---
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/profile1.png"
|
||||
width="1260"
|
||||
height="1260"
|
||||
alt="Documenso Profile of Timur"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">Let people sign anytime with Documenso Profiles. Try it [with my profile](https://app.documenso.com/p/timur).</figcaption>
|
||||
</figure>
|
||||
|
||||
> TLDR; We are launching a Documenso Profile where you can display your templates for everyone to sign at any time.
|
||||
|
||||
## Introducing Documenso Profiles
|
||||
|
||||
Today, I’m excited to announce that we are launching Documenso Profiles 🎉 While we have been focussing on the conventional signing experience so far, Direct Links and now profiles are our first steps to bring some long-awaited innovation back to digital signatures.
|
||||
|
||||
Documenso Profiles allows you to share any template with a public link in a very easy-to-understand way. Adding templates to your profiles allows everyone to sign your documents just when they need to. Forms, NDAs, Disclaimers, and even contracts are now available anytime they are needed. Profiles turn the classic signing flow on its head by letting the recipient sign first before the document’s owner becomes active. Booking links (e.g., Cal.com) and customer self-service portals (e.g., Stripe Billing Portal) are becoming the norm, so it’s time we did the same for signing.
|
||||
|
||||
## The Best Way to Share Your Singing Links
|
||||
|
||||
With profiles, we want to offer you the best way to share your standing Documenso Template Links in a public place. You can add your Documenso Profile to your social profiles, Email footer, Video Description, or wherever your audience, customers, and partners interact with you. The profile will be a public, trusted place to ensure you know who you are dealing with.
|
||||
|
||||
Looking at the classical social media fake profile problem, we know this is tricky to achieve. We will pay close attention to how profiles are used and how we can help the community use them easily and securely. As a first step towards this direction, we are introducing a trust badge next to your name. There will be 3 levels to start:
|
||||
|
||||
- Free Users: No Badge
|
||||
- Paid Users: Green Badge
|
||||
- Early Adopters: Gold Badge
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/profile2.png"
|
||||
width="1260"
|
||||
height="1260"
|
||||
alt="Documenso Profile Edit Screen"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">Add and remove templates anytime.</figcaption>
|
||||
</figure>
|
||||
|
||||
## An Open Economy built on Documenso
|
||||
|
||||
We see offering profiles as a first step towards creating a full economy on top of the open signing ecosystem we envision. While we want to keep building the product with the community, we also want an economy to grow on the open tech we create. This includes using the tech (profiles) or offering service on top of the tech (hosting and customization). Our ecosystem is still young, and the goal is not to have Documenso Inc. as the only commercial actor but to fill our own niche in a global and thriving open ecosystem. One of our guiding principles is solving things once and for all. While our focus will be on the core signing product, we want to enable others to offer templates for use on profiles or privately. A lot of contracts, forms, and other paperwork have been recreated countless times. Offering high-quality, peer-reviewed templates is a natural extension of the current platform. There will be free templates, offered as marketing for their creators, and paid templates, offered as actual products.
|
||||
|
||||
## What's next
|
||||
|
||||
While an open signing economy is really exciting, we know it will take time to mature. Building out a mature ecosystem for builders and entrepreneurs takes time. While this aspect of Documenso matures, we will be focussing on the core of the platform: Letting you integrate and embed Documenso wherever you want it.
|
||||
|
||||
If you already have a Documenso account, you can [activate your profile here](https://app.documenso.com/settings/public-profile).
|
||||
Don’t have an account and want to check out profiles? You can do so using our [free plan](https://documen.so/free).
|
||||
|
||||
As always, we want to hear from you on [Twitter / X](https://twitter.com/eltimuro) (DMs are open) or [Discord](https://documen.so/discord) if you have any questions, ideas, or comments.
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
@ -8,6 +8,7 @@ date: 2024-06-27
|
||||
tags:
|
||||
- NDA
|
||||
- Direct Links
|
||||
- Productivity
|
||||
---
|
||||
|
||||
<figure>
|
||||
@ -24,11 +25,12 @@ tags:
|
||||
> TLDR; Documenso makes sending NDAs faster, faster with templates, and even still faster using direct links.
|
||||
|
||||
## What is an NDA?
|
||||
|
||||
An NDA, or non-disclosure agreement, is a legal contract that establishes a confidential relationship. The parties involved agree not to disclose information covered by the agreement. NDAs are often used to protect sensitive information or trade secrets and to ensure that such information isn't made public by the recipient without permission. They are commonly used in business settings, such as during negotiations or when new employees are hired who will have access to proprietary information.
|
||||
|
||||
## Do I need an NDA?
|
||||
> Disclaimer: This is not legal advice, and the most important legal questions are often ultra-case-specific and should be discussed with a legal professional
|
||||
|
||||
> Disclaimer: This is not legal advice, and the most important legal questions are often ultra-case-specific and should be discussed with a legal professional
|
||||
|
||||
There is a solid amount of debate around the question of whether an NDA actually protects anyone and is worth the friction. Investors scoff at the idea of a startup requiring them to sign an NDA before disclosing their "billion dollar idea" as they see hundreds of them and are aware that without proper execution, there is nothing to protect.
|
||||
|
||||
@ -37,6 +39,7 @@ In another classical example, a big company and a small company e.g. a startup,
|
||||
That being said, as with most contracts, NDAs can be useful if both parties keep the spirit of the agreement. In this case, detailing in writing what can and cannot be disclosed is good for managing expectations and building trust. NDAs are also common practice in merger and acquisition projects and are often part of hiring critical roles within a company.
|
||||
|
||||
### Level 1: Basic Signing
|
||||
|
||||
If you need to sign an NDA, signing it with Documenso is incredibly fast already. Let's take a look at how to make it even faster. Simply uploading and sending is the most straightforward way to get this done. It works like this:
|
||||
|
||||
- Upload the NDA PDF template
|
||||
@ -56,7 +59,9 @@ If you need to sign an NDA, signing it with Documenso is incredibly fast already
|
||||
></video>
|
||||
|
||||
### Level 2: Using Templates
|
||||
|
||||
If you have to sign the same NDA multiple times with different people, you can create a template to save time. Creating a template is just as easy as creating a document, just skipping the sending step. After creating the template for your NDA, you can create a signable document with just 1 click. Simply fill in the recipient email, and you are done.
|
||||
|
||||
> Pro Tip: Check "Send Document" to immediately send it after filling out the recipient if you are familiar with the template.
|
||||
|
||||
<video
|
||||
@ -73,6 +78,7 @@ If you have to sign the same NDA multiple times with different people, you can c
|
||||
</figcaption>
|
||||
|
||||
### Level 3: Using a Direct Link
|
||||
|
||||
Using a pre-defined template is pretty fast, but can we make it even faster? Yes, we can! By adding a direct link to your NDA template and publishing it (internally or even externally). A [Direct Link](http://documenso.com/blog/announcing-direct-links) lets people sign your NDA without you lifting a finger. Everyone with access to the link can sign it at any time, making discussions of who sends what when a thing of the past. You can use Direct links with a pre-signed template for maximum convenience or with a second signer/ approver from your side to keep control over the process.
|
||||
|
||||
You can try it here and [sign a demo NDA](https://documen.so/demo-nda) with me.
|
||||
@ -80,9 +86,10 @@ You can try it here and [sign a demo NDA](https://documen.so/demo-nda) with me.
|
||||
> Pro Tip: Use [Zapier](https://documen.so/zapier) to get notified of that platform of your choice as soon as someone signs your link.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Signing NDAs is not always effective, but it can be necessary, so be sure to use a tool to make it easy and fast. Documenso is a great DocuSign alternative that helps you get it done. If you need to get an NDA out today, you can use the [Documenso Free plan](https://documen.so/free), which gives you 5 signatures per month and 3 Direct Link templates.
|
||||
|
||||
As always, we want to hear from you on [Twitter / X](https://twitter.com/eltimuro) (DMs are open) or [Discord](https://documen.so/discord) if you have any questions, ideas, or comments.
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
Timur
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
---
|
||||
title: Top 3 Signing Efficiency Hacks for Freelancers
|
||||
description: Streamlining signing contracts and paperwork is crucial for freelancers looking to save time and focus on their business. Take a look at these 3 tips on how Documenso can help you.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-07-04
|
||||
tags:
|
||||
- Productivity
|
||||
- Zapier
|
||||
- Direct Links
|
||||
---
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/zen.webp"
|
||||
width="1400"
|
||||
height="884"
|
||||
alt="Direct Links in Templates List View"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">Documenso helps you reduce your cognitive load while running your business.</figcaption>
|
||||
</figure>
|
||||
|
||||
> TLDR; Set up notifications using Zapier, use a redirect to send users to the next step, and start working async with direct links.
|
||||
|
||||
Signing all the paperwork of a freelance business can be a headache. Besides adding to the daily workload, it can pull much focus from your core tasks. Let's take a look at how Documenso can help you keep the cognitive load of admin tasks to a minimum:
|
||||
|
||||
## Tip 1: Set Up Notifications
|
||||
> At Documenso, we pipe almost everything relevant into Discord. This helps us naturally keep track and start conversations.
|
||||
|
||||
Getting customers and partners to sign off on critical agreements is no easy feat. It's even harder when keeping track forces you to switch between channels (email, WhatsApp, Discord, Slack, Notion, etc.). Setting up custom notifications for read or signed documents can help you keep track naturally. Using the Documenso [Zapier Integration](https://documen.so/zapier) lets you funnel important document events to the place where your focus is. Pipe send, read, and sign notifications directly to your Telegram account. Keep up to date on the go. Sync document completion events to your CRM. Ideally, a signed document does not give you any "homework”. Besides, you are already busy getting started on the actual work.
|
||||
|
||||
## Tip 2: Use Redirects
|
||||
Your client signed the deal? Your designer signed their statement of work? Great. Don't risk losing steam getting from one step to the next. Your customers and partners have just as much noise around them as you. Help them to stay on task by doing the next thing easily. Documenso lets you set up redirects to send signers to where they need to go next. Send your newest customer to a booking page for onboarding. Forward your designer to the next briefing you prepared. Keeping up flow is critical; sending everyone where they need to go while in focus mode helps get things done faster.
|
||||
|
||||
## Tip 3: Go Async with Direct Links
|
||||
> Try Direct Links by signing our [Supporters Pledge](https://documen.so/pledge)
|
||||
|
||||
Your customer wanting to approve an additional budget is great. It’s so great. In fact, you want to get on it as soon as possible and not have a calm moment until you send them something to sign. Even if you are fast, you are not mind-ready, though. So, what if your customers could do it whenever they like?
|
||||
|
||||
Direct Links lets you set up proposal templates and contracts, which are ready to sign anytime. You can include them in your [initial proposal](https://documen.so/freelance-proposal) or on your [public profile](https://documen.so/profiles). This way, customers can get more of your offering whenever they want.
|
||||
|
||||
## Conclusion
|
||||
Setting up notifications, redirects, and direct signing links using your favorite open source DocuSign alternative helps streamline your business and reduce the cognitive load of managing it. You can get everything mentioned here plus 5 free monthly signatures in the [Documenso Free Plan](https://documen.so/free).
|
||||
|
||||
As always, we want to hear from you on [Twitter / X](https://twitter.com/eltimuro) (DMs are open) or [Discord](https://documen.so/discord) if you have any questions, ideas, or comments.
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
|
||||
@ -4,11 +4,33 @@ title: Changelog - Documenso
|
||||
|
||||
# Changelog
|
||||
|
||||
Check out what's new in the latest major version and read what we think about it. You can find our releases on GitHub for more technical details [here](https://github.com/documenso/documenso/releases). You can find our [release candidates here](https://github.com/documenso/documenso/tags).
|
||||
Check out what's new in the latest version and read our thoughts on it. For more technical details, you can find our releases on GitHub [here](https://github.com/documenso/documenso/releases). You can find our [release candidates here](https://github.com/documenso/documenso/tags).
|
||||
|
||||
---
|
||||
|
||||
## v1.5.5 (latest)
|
||||
## v1.5.6 (latest)
|
||||
|
||||
### <small>Released 28th June 2024</small>
|
||||
|
||||
> This release contains [11 fixes](https://github.com/documenso/documenso/releases/tag/v1.5.6)
|
||||
|
||||
### 🕗 Show Creation Time
|
||||
|
||||
We are now displaying the document creation time in the documents view. This allows for easier identification of multiple documents created on the same day.
|
||||
|
||||
### 🔗 Direct Template Links
|
||||
|
||||
With this release, we are introducing direct link templates. This allows you to statically link to any template and let anyone with the link sign it at any time. A new document is created in your account when a template is signed. Templates with direct links still support all other template features, allowing you to create intricate workflows triggered by the signers.
|
||||
|
||||
Learn more about Direct Links [on our blog](https://documenso.com/blog/announcing-direct-links) or try them by signing the [Documenso Supporters Pledge](https://documen.so/pledge).
|
||||
|
||||
### 🛂 OpenID Connect (OIDC) Support
|
||||
|
||||
Thanks to [Matt Kilgore](https://github.com/tankerkiller125), Documenso now supports OIDC as an authentication provider. This allows self-hosted users to define whatever identity provider they want as long as it supports the OIDC. Azure, Zitadel, Authentik, KeyCloak, and Google all support OIDC.
|
||||
|
||||
---
|
||||
|
||||
## v1.5.5
|
||||
|
||||
### <small>Released 6th May 2024</small>
|
||||
|
||||
@ -51,5 +73,3 @@ On the security/ compliance side, we also added Signing Certificates and Audit L
|
||||
We are pretty hyped about this one: Since version 0.9, we relied on https://github.com/vbuch/node-signpdf to add the digital signatures to our documents. Since signing is at the heart of Documenso, we created our own rust-based library for signing. As of 1.5.4, Documenso's signing runs on @documenso/pdf-sign. The library offers a better architecture to enable signing with private keys that are not stored locally (e.g. via HSM). We are in the process of cleaning up the library to open source it like the rest of Documenso 🌱 The library will also help us to offer Long Term Validation (LTV) for signatures soon. While we are currently limited to signing with PKCS7-B, eventually, we plan to support all common signing standards like PAdES, CAdES, and XAdES.
|
||||
|
||||
---
|
||||
|
||||
´
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/marketing",
|
||||
"version": "1.2.3",
|
||||
"version": "1.6.0-rc.2",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -58,4 +58,4 @@
|
||||
"next": "$next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/marketing/public/blog/profile1.png
Normal file
BIN
apps/marketing/public/blog/profile1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
BIN
apps/marketing/public/blog/profile2.png
Normal file
BIN
apps/marketing/public/blog/profile2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
BIN
apps/marketing/public/blog/zen.webp
Normal file
BIN
apps/marketing/public/blog/zen.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "1.2.3",
|
||||
"version": "1.6.0-rc.2",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -76,4 +76,4 @@
|
||||
"next": "$next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { BarChart3, FileStack, Settings, User2, Wallet2 } from 'lucide-react';
|
||||
import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -46,7 +46,7 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/users">
|
||||
<User2 className="mr-2 h-5 w-5" />
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
Users
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@ -2,27 +2,28 @@ import {
|
||||
File,
|
||||
FileCheck,
|
||||
FileClock,
|
||||
FileCog,
|
||||
FileEdit,
|
||||
Mail,
|
||||
MailOpen,
|
||||
PenTool,
|
||||
User as UserIcon,
|
||||
UserPlus2,
|
||||
UserPlus,
|
||||
UserSquare2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||
import {
|
||||
getUserWithAtLeastOneDocumentPerMonth,
|
||||
getUserWithAtLeastOneDocumentSignedPerMonth,
|
||||
getUserWithSignedDocumentMonthlyGrowth,
|
||||
getUsersCount,
|
||||
getUsersWithSubscriptionsCount,
|
||||
} from '@documenso/lib/server-only/admin/get-users-stats';
|
||||
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
|
||||
|
||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||
|
||||
import { SignerConversionChart } from './signer-conversion-chart';
|
||||
import { UserWithDocumentChart } from './user-with-document';
|
||||
|
||||
export default async function AdminStatsPage() {
|
||||
@ -31,16 +32,18 @@ export default async function AdminStatsPage() {
|
||||
usersWithSubscriptionsCount,
|
||||
docStats,
|
||||
recipientStats,
|
||||
userWithAtLeastOneDocumentPerMonth,
|
||||
userWithAtLeastOneDocumentSignedPerMonth,
|
||||
signerConversionMonthly,
|
||||
// userWithAtLeastOneDocumentPerMonth,
|
||||
// userWithAtLeastOneDocumentSignedPerMonth,
|
||||
MONTHLY_USERS_SIGNED,
|
||||
] = await Promise.all([
|
||||
getUsersCount(),
|
||||
getUsersWithSubscriptionsCount(),
|
||||
getDocumentStats(),
|
||||
getRecipientsStats(),
|
||||
getUserWithAtLeastOneDocumentPerMonth(),
|
||||
getUserWithAtLeastOneDocumentSignedPerMonth(),
|
||||
getSignerConversionMonthly(),
|
||||
// getUserWithAtLeastOneDocumentPerMonth(),
|
||||
// getUserWithAtLeastOneDocumentSignedPerMonth(),
|
||||
getUserWithSignedDocumentMonthlyGrowth(),
|
||||
]);
|
||||
|
||||
@ -49,14 +52,15 @@ export default async function AdminStatsPage() {
|
||||
<h2 className="text-4xl font-semibold">Instance Stats</h2>
|
||||
|
||||
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<CardMetric icon={UserIcon} title="Total Users" value={usersCount} />
|
||||
<CardMetric icon={Users} title="Total Users" value={usersCount} />
|
||||
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
|
||||
<CardMetric
|
||||
icon={UserPlus2}
|
||||
icon={UserPlus}
|
||||
title="Active Subscriptions"
|
||||
value={usersWithSubscriptionsCount}
|
||||
/>
|
||||
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
|
||||
|
||||
<CardMetric icon={FileCog} title="App Version" value={`v${process.env.APP_VERSION}`} />
|
||||
</div>
|
||||
|
||||
<div className="mt-16 gap-8">
|
||||
@ -88,7 +92,7 @@ export default async function AdminStatsPage() {
|
||||
|
||||
<div className="mt-16">
|
||||
<h3 className="text-3xl font-semibold">Charts</h3>
|
||||
<div className="mt-5 grid grid-cols-2 gap-10">
|
||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||
<UserWithDocumentChart
|
||||
data={MONTHLY_USERS_SIGNED}
|
||||
title="MAU (created document)"
|
||||
@ -100,6 +104,12 @@ export default async function AdminStatsPage() {
|
||||
title="MAU (had document completed)"
|
||||
tooltip="Monthly Active Users: Users that had at least one of their documents completed"
|
||||
/>
|
||||
<SignerConversionChart title="Signers that Signed Up" data={signerConversionMonthly} />
|
||||
<SignerConversionChart
|
||||
title="Total Signers that Signed Up"
|
||||
data={signerConversionMonthly}
|
||||
cummulative
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import type { GetSignerConversionMonthlyResult } from '@documenso/lib/server-only/user/get-signer-conversion';
|
||||
|
||||
export type SignerConversionChartProps = {
|
||||
className?: string;
|
||||
title: string;
|
||||
cummulative?: boolean;
|
||||
data: GetSignerConversionMonthlyResult;
|
||||
};
|
||||
|
||||
export const SignerConversionChart = ({
|
||||
className,
|
||||
data,
|
||||
title,
|
||||
cummulative = false,
|
||||
}: SignerConversionChartProps) => {
|
||||
const formattedData = [...data].reverse().map(({ month, count, cume_count }) => {
|
||||
return {
|
||||
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('MMM yyyy'),
|
||||
count: Number(count),
|
||||
signed_count: Number(cume_count),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
|
||||
<div className="mb-6 flex px-4">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={formattedData}>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
|
||||
<Tooltip
|
||||
labelStyle={{
|
||||
color: 'hsl(var(--primary-foreground))',
|
||||
}}
|
||||
formatter={(value, name) => [
|
||||
Number(value).toLocaleString('en-US'),
|
||||
name === 'Recipients',
|
||||
]}
|
||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||
/>
|
||||
|
||||
<Bar
|
||||
dataKey={cummulative ? 'signed_count' : 'count'}
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={60}
|
||||
label="Recipients"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -68,7 +68,7 @@ export const UserWithDocumentChart = ({
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart className="bg-white" data={formattedData(data, completed)}>
|
||||
<BarChart data={formattedData(data, completed)}>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
|
||||
|
||||
@ -172,6 +172,7 @@ export const EditDocumentForm = ({
|
||||
teamId: team?.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||
globalActionAuth: data.globalActionAuth ?? null,
|
||||
},
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
EyeIcon,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
MoveRight,
|
||||
Pencil,
|
||||
Share,
|
||||
Trash2,
|
||||
@ -37,6 +38,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||
import { DeleteDocumentDialog } from './delete-document-dialog';
|
||||
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
||||
import { MoveDocumentDialog } from './move-document-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Document & {
|
||||
@ -53,6 +55,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
@ -157,6 +160,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* We don't want to allow teams moving documents across at the moment. */}
|
||||
{!team && (
|
||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
Move to Team
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* No point displaying this if there's no functionality. */}
|
||||
{/* <DropdownMenuItem disabled>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
@ -199,6 +210,12 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
canManageDocument={canManageDocument}
|
||||
/>
|
||||
|
||||
<MoveDocumentDialog
|
||||
documentId={row.id}
|
||||
open={isMoveDialogOpen}
|
||||
onOpenChange={setMoveDialogOpen}
|
||||
/>
|
||||
|
||||
{isDuplicateDialogOpen && (
|
||||
<DuplicateDocumentDialog
|
||||
id={row.id}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
@ -10,7 +11,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team, TeamEmail } from '@documenso/prisma/client';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
@ -94,6 +95,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
|
||||
<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">
|
||||
{team.avatarImageId && (
|
||||
<AvatarImage src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
|
||||
117
apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx
Normal file
117
apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type MoveDocumentDialogProps = {
|
||||
documentId: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||
|
||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
const { mutateAsync: moveDocument, isLoading } = trpc.document.moveDocumentToTeam.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
toast({
|
||||
title: 'Document moved',
|
||||
description: 'The document has been successfully moved to the selected team.',
|
||||
duration: 5000,
|
||||
});
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message || 'An error occurred while moving the document.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onMove = async () => {
|
||||
if (!selectedTeamId) return;
|
||||
await moveDocument({ documentId, teamId: selectedTeamId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Move Document to Team</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a team to move this document to. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a team" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingTeams ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
Loading teams...
|
||||
</SelectItem>
|
||||
) : (
|
||||
teams?.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id.toString()}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-8 w-8">
|
||||
{team.avatarImageId && (
|
||||
<AvatarImage
|
||||
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AvatarFallback className="text-sm text-gray-400">
|
||||
{team.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span>{team.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}>
|
||||
{isLoading ? 'Moving...' : 'Move'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -3,6 +3,7 @@
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { BellIcon } from 'lucide-react';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
@ -55,6 +56,9 @@ export const TeamInvitations = () => {
|
||||
{data.map((invitation) => (
|
||||
<li key={invitation.teamId}>
|
||||
<AvatarWithText
|
||||
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${
|
||||
invitation.team.avatarImageId
|
||||
}`}
|
||||
className="w-full max-w-none py-4"
|
||||
avatarFallback={invitation.team.name.slice(0, 1)}
|
||||
primaryText={
|
||||
|
||||
@ -132,6 +132,7 @@ export const EditTemplateForm = ({
|
||||
teamId: team?.id,
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||
globalActionAuth: data.globalActionAuth ?? null,
|
||||
},
|
||||
|
||||
@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Copy, Edit, MoreHorizontal, Share2Icon, Trash2 } from 'lucide-react';
|
||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||
@ -18,6 +18,7 @@ import {
|
||||
|
||||
import { DeleteTemplateDialog } from './delete-template-dialog';
|
||||
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||
import { MoveTemplateDialog } from './move-template-dialog';
|
||||
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
@ -36,6 +37,7 @@ export const DataTableActionDropdown = ({
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
@ -73,6 +75,13 @@ export const DataTableActionDropdown = ({
|
||||
Direct link
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!teamId && (
|
||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
Move to Team
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
@ -95,6 +104,12 @@ export const DataTableActionDropdown = ({
|
||||
onOpenChange={setTemplateDirectLinkDialogOpen}
|
||||
/>
|
||||
|
||||
<MoveTemplateDialog
|
||||
templateId={row.id}
|
||||
open={isMoveDialogOpen}
|
||||
onOpenChange={setMoveDialogOpen}
|
||||
/>
|
||||
|
||||
<DeleteTemplateDialog
|
||||
id={row.id}
|
||||
teamId={row.teamId || undefined}
|
||||
|
||||
120
apps/web/src/app/(dashboard)/templates/move-template-dialog.tsx
Normal file
120
apps/web/src/app/(dashboard)/templates/move-template-dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type MoveTemplateDialogProps = {
|
||||
templateId: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTemplateDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||
|
||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
const { mutateAsync: moveTemplate, isLoading } = trpc.template.moveTemplateToTeam.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
toast({
|
||||
title: 'Template moved',
|
||||
description: 'The template has been successfully moved to the selected team.',
|
||||
duration: 5000,
|
||||
});
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message || 'An error occurred while moving the template.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onMove = async () => {
|
||||
if (!selectedTeamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await moveTemplate({ templateId, teamId: selectedTeamId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Move Template to Team</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a team to move this template to. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a team" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingTeams ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
Loading teams...
|
||||
</SelectItem>
|
||||
) : (
|
||||
teams?.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id.toString()}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-8 w-8">
|
||||
{team.avatarImageId && (
|
||||
<AvatarImage
|
||||
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AvatarFallback className="text-sm text-gray-400">
|
||||
{team.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span>{team.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}>
|
||||
{isLoading ? 'Moving...' : 'Move'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
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 { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
import { TemplatesDataTable } from './data-table-templates';
|
||||
import { EmptyTemplateState } from './empty-state';
|
||||
@ -39,6 +40,9 @@ export const TemplatesPageView = async ({ searchParams = {}, team }: TemplatesPa
|
||||
<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">
|
||||
{team.avatarImageId && (
|
||||
<AvatarImage src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs text-gray-400">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
|
||||
@ -42,7 +42,7 @@ export const DirectTemplatePageView = ({
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { email, setEmail } = useRequiredSigningContext();
|
||||
const { email, fullName, setEmail } = useRequiredSigningContext();
|
||||
const { recipient, setRecipient } = useRequiredDocumentAuthContext();
|
||||
|
||||
const [step, setStep] = useState<DirectTemplateStep>('configure');
|
||||
@ -84,6 +84,7 @@ export const DirectTemplatePageView = ({
|
||||
try {
|
||||
const token = await createDocumentFromDirectTemplate({
|
||||
directTemplateToken,
|
||||
directRecipientName: fullName,
|
||||
directRecipientEmail: recipient.email,
|
||||
templateUpdatedAt: template.updatedAt,
|
||||
signedFieldValues: fields.map((field) => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { CheckCircle2, Clock } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
@ -64,6 +65,7 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
|
||||
<div className="flex flex-row items-center justify-between pt-4">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
|
||||
avatarFallback={extractInitials(
|
||||
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
|
||||
)}
|
||||
|
||||
@ -25,7 +25,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-primary-forground flex items-end text-sm font-medium leading-tight">
|
||||
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||
import type { TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -22,11 +23,18 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
export type LeaveTeamDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
teamAvatarImageId?: string | null;
|
||||
role: TeamMemberRole;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LeaveTeamDialog = ({ trigger, teamId, teamName, role }: LeaveTeamDialogProps) => {
|
||||
export const LeaveTeamDialog = ({
|
||||
trigger,
|
||||
teamId,
|
||||
teamName,
|
||||
teamAvatarImageId,
|
||||
role,
|
||||
}: LeaveTeamDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
@ -70,6 +78,7 @@ export const LeaveTeamDialog = ({ trigger, teamId, teamName, role }: LeaveTeamDi
|
||||
<Alert variant="neutral" padding="tight">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${teamAvatarImageId}`}
|
||||
avatarFallback={teamName.slice(0, 1).toUpperCase()}
|
||||
primaryText={teamName}
|
||||
secondaryText={TEAM_MEMBER_ROLE_MAP[role]}
|
||||
|
||||
@ -4,7 +4,7 @@ import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL, WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
@ -62,6 +62,7 @@ export const CurrentUserTeamsDataTable = () => {
|
||||
cell: ({ row }) => (
|
||||
<Link href={`/t/${row.original.url}`} scroll={false}>
|
||||
<AvatarWithText
|
||||
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${row.original.avatarImageId}`}
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
||||
primaryText={
|
||||
@ -98,6 +99,7 @@ export const CurrentUserTeamsDataTable = () => {
|
||||
<LeaveTeamDialog
|
||||
teamId={row.original.id}
|
||||
teamName={row.original.name}
|
||||
teamAvatarImageId={row.original.avatarImageId}
|
||||
role={row.original.currentTeamMember.role}
|
||||
trigger={
|
||||
<Button
|
||||
|
||||
@ -157,6 +157,7 @@ export const DocumentHistorySheet = ({
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM },
|
||||
() => null,
|
||||
)
|
||||
.with(
|
||||
@ -270,6 +271,23 @@ export const DocumentHistorySheet = ({
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.with(
|
||||
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED },
|
||||
({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Old',
|
||||
value: data.from,
|
||||
},
|
||||
{
|
||||
key: 'New',
|
||||
value: data.to,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
@ -304,7 +322,6 @@ export const DocumentHistorySheet = ({
|
||||
]}
|
||||
/>
|
||||
))
|
||||
|
||||
.exhaustive()}
|
||||
|
||||
{isUserDetailsVisible && (
|
||||
|
||||
@ -128,6 +128,7 @@ Here's a markdown table documenting all the provided environment variables:
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.6.0-rc.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.6.0-rc.2",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -37,7 +39,7 @@
|
||||
},
|
||||
"apps/marketing": {
|
||||
"name": "@documenso/marketing",
|
||||
"version": "1.2.3",
|
||||
"version": "1.6.0-rc.2",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@documenso/assets": "*",
|
||||
@ -98,7 +100,7 @@
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@documenso/web",
|
||||
"version": "1.2.3",
|
||||
"version": "1.6.0-rc.2",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
@ -30227,7 +30229,7 @@
|
||||
},
|
||||
"packages/api": {
|
||||
"name": "@documenso/api",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
@ -30456,7 +30458,7 @@
|
||||
},
|
||||
"packages/app-tests": {
|
||||
"name": "@documenso/app-tests",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"license": "to-update",
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.1"
|
||||
@ -30488,7 +30490,7 @@
|
||||
},
|
||||
"packages/ee": {
|
||||
"name": "@documenso/ee",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"license": "COMMERCIAL",
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
@ -30504,7 +30506,7 @@
|
||||
},
|
||||
"packages/email": {
|
||||
"name": "@documenso/email",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@documenso/nodemailer-resend": "2.0.0",
|
||||
@ -31665,7 +31667,7 @@
|
||||
},
|
||||
"packages/lib": {
|
||||
"name": "@documenso/lib",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@auth/kysely-adapter": "^0.6.0",
|
||||
@ -31762,7 +31764,7 @@
|
||||
},
|
||||
"packages/prisma": {
|
||||
"name": "@documenso/prisma",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.4.2",
|
||||
@ -31799,7 +31801,7 @@
|
||||
},
|
||||
"packages/signing": {
|
||||
"name": "@documenso/signing",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"license": "AGPLv3",
|
||||
"dependencies": {
|
||||
"@documenso/pdf-sign": "^0.1.0",
|
||||
@ -31854,7 +31856,7 @@
|
||||
},
|
||||
"packages/trpc": {
|
||||
"name": "@documenso/trpc",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.6.0-rc.2",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"build:web": "turbo run build --filter=@documenso/web",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/api",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"license": "MIT",
|
||||
@ -27,4 +27,4 @@
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ import {
|
||||
ZGetDocumentsQuerySchema,
|
||||
ZGetTemplatesQuerySchema,
|
||||
ZNoBodyMutationSchema,
|
||||
ZResendDocumentForSigningMutationSchema,
|
||||
ZSendDocumentForSigningMutationSchema,
|
||||
ZSuccessfulDeleteTemplateResponseSchema,
|
||||
ZSuccessfulDocumentResponseSchema,
|
||||
@ -25,6 +26,7 @@ import {
|
||||
ZSuccessfulGetTemplateResponseSchema,
|
||||
ZSuccessfulGetTemplatesResponseSchema,
|
||||
ZSuccessfulRecipientResponseSchema,
|
||||
ZSuccessfulResendDocumentResponseSchema,
|
||||
ZSuccessfulResponseSchema,
|
||||
ZSuccessfulSigningResponseSchema,
|
||||
ZUnsuccessfulResponseSchema,
|
||||
@ -161,6 +163,20 @@ export const ApiContractV1 = c.router(
|
||||
summary: 'Send a document for signing',
|
||||
},
|
||||
|
||||
resendDocument: {
|
||||
method: 'POST',
|
||||
path: '/api/v1/documents/:id/resend',
|
||||
body: ZResendDocumentForSigningMutationSchema,
|
||||
responses: {
|
||||
200: ZSuccessfulResendDocumentResponseSchema,
|
||||
400: ZUnsuccessfulResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Re-send a document for signing',
|
||||
},
|
||||
|
||||
deleteDocument: {
|
||||
method: 'DELETE',
|
||||
path: '/api/v1/documents/:id',
|
||||
|
||||
@ -9,6 +9,7 @@ import { createDocument } from '@documenso/lib/server-only/document/create-docum
|
||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||
import { createField } from '@documenso/lib/server-only/field/create-field';
|
||||
@ -232,6 +233,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
|
||||
const document = await createDocument({
|
||||
title: body.title,
|
||||
externalId: body.externalId || null,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
formValues: body.formValues,
|
||||
@ -397,6 +399,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
teamId: team?.id,
|
||||
data: {
|
||||
title: fileName,
|
||||
externalId: body.externalId || null,
|
||||
formValues: body.formValues,
|
||||
documentData: {
|
||||
connect: {
|
||||
@ -453,6 +456,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
try {
|
||||
document = await createDocumentFromTemplate({
|
||||
templateId,
|
||||
externalId: body.externalId || null,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
recipients: body.recipients,
|
||||
@ -600,6 +604,35 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
}
|
||||
}),
|
||||
|
||||
resendDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
const { recipients } = args.body;
|
||||
|
||||
try {
|
||||
await resendDocument({
|
||||
userId: user.id,
|
||||
documentId: Number(documentId),
|
||||
recipients,
|
||||
teamId: team?.id,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
message: 'Document resend successfully initiated',
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
message: 'An error has occured while resending the document',
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
createRecipient: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
const { name, email, role } = args.body;
|
||||
|
||||
@ -29,6 +29,7 @@ export type TDeleteDocumentMutationSchema = typeof ZDeleteDocumentMutationSchema
|
||||
|
||||
export const ZSuccessfulDocumentResponseSchema = z.object({
|
||||
id: z.number(),
|
||||
externalId: z.string().nullish(),
|
||||
userId: z.number(),
|
||||
teamId: z.number().nullish(),
|
||||
title: z.string(),
|
||||
@ -57,6 +58,20 @@ export const ZSendDocumentForSigningMutationSchema = z
|
||||
|
||||
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||
|
||||
export const ZResendDocumentForSigningMutationSchema = z.object({
|
||||
recipients: z.array(z.number()),
|
||||
});
|
||||
|
||||
export type TResendDocumentForSigningMutationSchema = z.infer<
|
||||
typeof ZResendDocumentForSigningMutationSchema
|
||||
>;
|
||||
|
||||
export const ZSuccessfulResendDocumentResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export type TResendDocumentResponseSchema = z.infer<typeof ZSuccessfulResendDocumentResponseSchema>;
|
||||
|
||||
export const ZUploadDocumentSuccessfulSchema = z.object({
|
||||
url: z.string(),
|
||||
key: z.string(),
|
||||
@ -70,6 +85,7 @@ export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSucc
|
||||
|
||||
export const ZCreateDocumentMutationSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
@ -94,6 +110,7 @@ export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutati
|
||||
export const ZCreateDocumentMutationResponseSchema = z.object({
|
||||
uploadUrl: z.string().min(1),
|
||||
documentId: z.number(),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
@ -113,6 +130,7 @@ export type TCreateDocumentMutationResponseSchema = z.infer<
|
||||
|
||||
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
@ -139,6 +157,7 @@ export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||
|
||||
export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
documentId: z.number(),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
@ -158,6 +177,7 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
|
||||
|
||||
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z
|
||||
.array(
|
||||
z.object({
|
||||
@ -194,6 +214,7 @@ export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
|
||||
|
||||
export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
documentId: z.number(),
|
||||
externalId: z.string().nullish(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
@ -332,6 +353,7 @@ export const ZTemplateMetaSchema = z.object({
|
||||
|
||||
export const ZTemplateSchema = z.object({
|
||||
id: z.number(),
|
||||
externalId: z.string().nullish(),
|
||||
type: z.nativeEnum(TemplateType),
|
||||
title: z.string(),
|
||||
userId: z.number(),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/app-tests",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"license": "to-update",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
@ -20,4 +20,4 @@
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/ee",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"license": "COMMERCIAL",
|
||||
|
||||
@ -46,10 +46,13 @@ const getTransport = () => {
|
||||
host: process.env.NEXT_PRIVATE_SMTP_HOST ?? 'localhost:2500',
|
||||
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
|
||||
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.NEXT_PRIVATE_SMTP_USERNAME ?? '',
|
||||
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
|
||||
},
|
||||
ignoreTLS: process.env.NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS === 'true',
|
||||
auth: process.env.NEXT_PRIVATE_SMTP_USERNAME
|
||||
? {
|
||||
user: process.env.NEXT_PRIVATE_SMTP_USERNAME,
|
||||
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/email",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"license": "MIT",
|
||||
@ -45,4 +45,4 @@
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"tsup": "^7.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,12 +5,14 @@ export interface TemplateDocumentCompletedProps {
|
||||
downloadLink: string;
|
||||
documentName: string;
|
||||
assetBaseUrl: string;
|
||||
customBody?: string;
|
||||
}
|
||||
|
||||
export const TemplateDocumentCompleted = ({
|
||||
downloadLink,
|
||||
documentName,
|
||||
assetBaseUrl,
|
||||
customBody,
|
||||
}: TemplateDocumentCompletedProps) => {
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
@ -34,7 +36,7 @@ export const TemplateDocumentCompleted = ({
|
||||
</Section>
|
||||
|
||||
<Text className="text-primary mb-0 text-center text-lg font-semibold">
|
||||
“{documentName}” was signed by all signers
|
||||
{customBody ?? `“${documentName}” was signed by all signers`}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
|
||||
@ -5,12 +5,15 @@ import type { TemplateDocumentCompletedProps } from '../template-components/temp
|
||||
import { TemplateDocumentCompleted } from '../template-components/template-document-completed';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps>;
|
||||
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps> & {
|
||||
customBody?: string;
|
||||
};
|
||||
|
||||
export const DocumentCompletedEmailTemplate = ({
|
||||
downloadLink = 'https://documenso.com',
|
||||
documentName = 'Open Source Pledge.pdf',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
customBody,
|
||||
}: DocumentCompletedEmailTemplateProps) => {
|
||||
const previewText = `Completed Document`;
|
||||
|
||||
@ -45,6 +48,7 @@ export const DocumentCompletedEmailTemplate = ({
|
||||
downloadLink={downloadLink}
|
||||
documentName={documentName}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
customBody={customBody}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/lib",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"license": "MIT",
|
||||
|
||||
@ -38,7 +38,7 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
|
||||
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||
const senderAdress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
const senderAddress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
|
||||
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
|
||||
assetBaseUrl,
|
||||
@ -52,7 +52,7 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
|
||||
},
|
||||
from: {
|
||||
name: senderName,
|
||||
address: senderAdress,
|
||||
address: senderAddress,
|
||||
},
|
||||
subject: 'Please confirm your email',
|
||||
html: render(confirmationTemplate),
|
||||
|
||||
@ -11,6 +11,7 @@ import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
title: string;
|
||||
externalId?: string | null;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentDataId: string;
|
||||
@ -21,6 +22,7 @@ export type CreateDocumentOptions = {
|
||||
export const createDocument = async ({
|
||||
userId,
|
||||
title,
|
||||
externalId,
|
||||
documentDataId,
|
||||
teamId,
|
||||
formValues,
|
||||
@ -50,6 +52,7 @@ export const createDocument = async ({
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
externalId,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
|
||||
@ -31,7 +31,7 @@ export const findDocumentAuditLogs = async ({
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
await prisma.document.findFirstOrThrow({
|
||||
const documentFilter = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
OR: [
|
||||
@ -67,6 +67,7 @@ export const findDocumentAuditLogs = async ({
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
81
packages/lib/server-only/document/move-document-to-team.ts
Normal file
81
packages/lib/server-only/document/move-document-to-team.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type MoveDocumentToTeamOptions = {
|
||||
documentId: number;
|
||||
teamId: number;
|
||||
userId: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const moveDocumentToTeam = async ({
|
||||
documentId,
|
||||
teamId,
|
||||
userId,
|
||||
requestMetadata,
|
||||
}: MoveDocumentToTeamOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
const document = await tx.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Document not found or already associated with a team.',
|
||||
});
|
||||
}
|
||||
|
||||
const team = await tx.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You are not a member of this team.',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedDocument = await tx.document.update({
|
||||
where: { id: documentId },
|
||||
data: { teamId },
|
||||
});
|
||||
|
||||
const log = await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
|
||||
documentId: updatedDocument.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
movedByUserId: userId,
|
||||
fromPersonalAccount: true,
|
||||
toTeamId: teamId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
});
|
||||
};
|
||||
@ -4,12 +4,14 @@ import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentSource } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
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';
|
||||
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
documentId: number;
|
||||
@ -23,6 +25,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
User: true,
|
||||
team: {
|
||||
@ -38,6 +41,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const isDirectTemplate = document?.source === DocumentSource.TEMPLATE_DIRECT_LINK;
|
||||
|
||||
if (document.Recipient.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
@ -106,12 +111,22 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const customEmailTemplate = {
|
||||
'signer.name': recipient.name,
|
||||
'signer.email': recipient.email,
|
||||
'document.name': document.title,
|
||||
};
|
||||
|
||||
const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`;
|
||||
|
||||
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||
documentName: document.title,
|
||||
assetBaseUrl,
|
||||
downloadLink: recipient.email === owner.email ? documentOwnerDownloadLink : downloadLink,
|
||||
customBody:
|
||||
isDirectTemplate && document.documentMeta?.message
|
||||
? renderCustomEmailTemplate(document.documentMeta.message, customEmailTemplate)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
@ -125,7 +140,10 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Signing Complete!',
|
||||
subject:
|
||||
isDirectTemplate && document.documentMeta?.subject
|
||||
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)
|
||||
: 'Signing Complete!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
attachments: [
|
||||
|
||||
@ -18,6 +18,7 @@ export type UpdateDocumentSettingsOptions = {
|
||||
documentId: number;
|
||||
data: {
|
||||
title?: string;
|
||||
externalId?: string | null;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||
};
|
||||
@ -91,6 +92,7 @@ export const updateDocumentSettings = async ({
|
||||
}
|
||||
|
||||
const isTitleSame = data.title === document.title;
|
||||
const isExternalIdSame = data.externalId === document.externalId;
|
||||
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
|
||||
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
|
||||
|
||||
@ -118,6 +120,21 @@ export const updateDocumentSettings = async ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!isExternalIdSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
from: document.externalId,
|
||||
to: data.externalId || '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isGlobalAccessSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
@ -165,6 +182,7 @@ export const updateDocumentSettings = async ({
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
authOptions,
|
||||
},
|
||||
});
|
||||
|
||||
@ -15,6 +15,7 @@ export const getTeamInvitations = async ({ email }: GetTeamInvitationsOptions) =
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
avatarImageId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -39,6 +39,7 @@ import { sendDocument } from '../document/send-document';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
|
||||
export type CreateDocumentFromDirectTemplateOptions = {
|
||||
directRecipientName?: string;
|
||||
directRecipientEmail: string;
|
||||
directTemplateToken: string;
|
||||
signedFieldValues: TSignFieldWithTokenMutationSchema[];
|
||||
@ -57,6 +58,7 @@ type CreatedDirectRecipientField = {
|
||||
};
|
||||
|
||||
export const createDocumentFromDirectTemplate = async ({
|
||||
directRecipientName: initialDirectRecipientName,
|
||||
directRecipientEmail,
|
||||
directTemplateToken,
|
||||
signedFieldValues,
|
||||
@ -110,7 +112,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
const directRecipientName = user?.name;
|
||||
const directRecipientName = user?.name || initialDirectRecipientName;
|
||||
|
||||
// Ensure typesafety when we add more options.
|
||||
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
||||
@ -132,6 +134,8 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
|
||||
const metaTimezone = template.templateMeta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE;
|
||||
const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT;
|
||||
const metaEmailMessage = template.templateMeta?.message || '';
|
||||
const metaEmailSubject = template.templateMeta?.subject || '';
|
||||
|
||||
// Associate, validate and map to a query every direct template recipient field with the provided fields.
|
||||
const createDirectRecipientFieldArgs = await Promise.all(
|
||||
@ -250,6 +254,14 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
}),
|
||||
},
|
||||
},
|
||||
documentMeta: {
|
||||
create: {
|
||||
timezone: metaTimezone,
|
||||
dateFormat: metaDateFormat,
|
||||
message: metaEmailMessage,
|
||||
subject: metaEmailSubject,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
|
||||
@ -33,6 +33,7 @@ export type CreateDocumentFromTemplateResponse = Awaited<
|
||||
|
||||
export type CreateDocumentFromTemplateOptions = {
|
||||
templateId: number;
|
||||
externalId?: string | null;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
recipients: {
|
||||
@ -58,6 +59,7 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
|
||||
export const createDocumentFromTemplate = async ({
|
||||
templateId,
|
||||
externalId,
|
||||
userId,
|
||||
teamId,
|
||||
recipients,
|
||||
@ -147,6 +149,7 @@ export const createDocumentFromTemplate = async ({
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
source: DocumentSource.TEMPLATE,
|
||||
externalId,
|
||||
templateId: template.id,
|
||||
userId,
|
||||
teamId: template.teamId,
|
||||
|
||||
57
packages/lib/server-only/template/move-template-to-team.ts
Normal file
57
packages/lib/server-only/template/move-template-to-team.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type MoveTemplateToTeamOptions = {
|
||||
templateId: number;
|
||||
teamId: number;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const moveTemplateToTeam = async ({
|
||||
templateId,
|
||||
teamId,
|
||||
userId,
|
||||
}: MoveTemplateToTeamOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const template = await tx.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Template not found or already associated with a team.',
|
||||
});
|
||||
}
|
||||
|
||||
const team = await tx.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You are not a member of this team.',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedTemplate = await tx.template.update({
|
||||
where: { id: templateId },
|
||||
data: { teamId },
|
||||
});
|
||||
|
||||
return updatedTemplate;
|
||||
});
|
||||
};
|
||||
@ -15,6 +15,7 @@ export type UpdateTemplateSettingsOptions = {
|
||||
templateId: number;
|
||||
data: {
|
||||
title?: string;
|
||||
externalId?: string | null;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||
publicTitle?: string;
|
||||
@ -99,6 +100,7 @@ export const updateTemplateSettings = async ({
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId || null,
|
||||
type: data.type,
|
||||
publicDescription: data.publicDescription,
|
||||
publicTitle: data.publicTitle,
|
||||
|
||||
34
packages/lib/server-only/user/get-signer-conversion.ts
Normal file
34
packages/lib/server-only/user/get-signer-conversion.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
export const getSignerConversionMonthly = async () => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('Recipient')
|
||||
.innerJoin('User', 'Recipient.email', 'User.email')
|
||||
.select(({ fn }) => [
|
||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']).as('month'),
|
||||
fn.count('Recipient.email').distinct().as('count'),
|
||||
fn
|
||||
.sum(fn.count('Recipient.email').distinct())
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']) as any))
|
||||
.as('cume_count'),
|
||||
])
|
||||
.where('Recipient.signedAt', 'is not', null)
|
||||
.where('Recipient.signedAt', '<', (eb) => eb.ref('User.createdAt'))
|
||||
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'User.createdAt']))
|
||||
.orderBy('month', 'desc');
|
||||
|
||||
const result = await qb.execute();
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
cume_count: Number(row.cume_count),
|
||||
}));
|
||||
};
|
||||
|
||||
export type GetSignerConversionMonthlyResult = Awaited<
|
||||
ReturnType<typeof getSignerConversionMonthly>
|
||||
>;
|
||||
@ -35,6 +35,8 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
|
||||
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
|
||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
|
||||
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
|
||||
]);
|
||||
|
||||
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
@ -354,6 +356,17 @@ export const ZDocumentAuditLogEventDocumentTitleUpdatedSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document external ID updated.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED),
|
||||
data: z.object({
|
||||
from: z.string().nullish(),
|
||||
to: z.string().nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Field created.
|
||||
*/
|
||||
@ -410,6 +423,18 @@ export const ZDocumentAuditLogEventRecipientRemovedSchema = z.object({
|
||||
data: ZBaseRecipientDataSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document moved to team.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM),
|
||||
data: z.object({
|
||||
movedByUserId: z.number(),
|
||||
fromPersonalAccount: z.boolean(),
|
||||
toTeamId: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@ -427,6 +452,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventDocumentCompletedSchema,
|
||||
ZDocumentAuditLogEventDocumentCreatedSchema,
|
||||
ZDocumentAuditLogEventDocumentDeletedSchema,
|
||||
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
|
||||
@ -436,6 +462,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
||||
ZDocumentAuditLogEventDocumentSentSchema,
|
||||
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
||||
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
|
||||
ZDocumentAuditLogEventFieldCreatedSchema,
|
||||
ZDocumentAuditLogEventFieldRemovedSchema,
|
||||
ZDocumentAuditLogEventFieldUpdatedSchema,
|
||||
|
||||
@ -332,10 +332,18 @@ export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId
|
||||
anonymous: 'Document title updated',
|
||||
identified: 'updated the document title',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
|
||||
anonymous: 'Document external ID updated',
|
||||
identified: 'updated the document external ID',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
|
||||
anonymous: 'Document sent',
|
||||
identified: 'sent the document',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
|
||||
anonymous: 'Document moved to team',
|
||||
identified: 'moved the document to team',
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const action = RECIPIENT_ROLES_DESCRIPTION[data.recipientRole as RecipientRole]?.actioned;
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" ADD COLUMN "externalId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Template" ADD COLUMN "externalId" TEXT;
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/prisma",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"license": "MIT",
|
||||
@ -34,4 +34,4 @@
|
||||
"tsx": "^4.11.0",
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -283,6 +283,7 @@ enum DocumentSource {
|
||||
|
||||
model Document {
|
||||
id Int @id @default(autoincrement())
|
||||
externalId String?
|
||||
userId Int
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
authOptions Json?
|
||||
@ -589,6 +590,7 @@ model TemplateMeta {
|
||||
|
||||
model Template {
|
||||
id Int @id @default(autoincrement())
|
||||
externalId String?
|
||||
type TemplateType @default(PRIVATE)
|
||||
title String
|
||||
userId Int
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/signing",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"license": "AGPLv3",
|
||||
@ -20,4 +20,4 @@
|
||||
"devDependencies": {
|
||||
"vitest": "^1.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/trpc",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"license": "MIT",
|
||||
@ -25,4 +25,4 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||
import { moveDocumentToTeam } from '@documenso/lib/server-only/document/move-document-to-team';
|
||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
@ -32,6 +33,7 @@ import {
|
||||
ZGetDocumentByIdQuerySchema,
|
||||
ZGetDocumentByTokenQuerySchema,
|
||||
ZGetDocumentWithDetailsByIdQuerySchema,
|
||||
ZMoveDocumentsToTeamSchema,
|
||||
ZResendDocumentMutationSchema,
|
||||
ZSearchDocumentsMutationSchema,
|
||||
ZSendDocumentMutationSchema,
|
||||
@ -158,6 +160,33 @@ export const documentRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
moveDocumentToTeam: authenticatedProcedure
|
||||
.input(ZMoveDocumentsToTeamSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { documentId, teamId } = input;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
return await moveDocumentToTeam({
|
||||
documentId,
|
||||
teamId,
|
||||
userId,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof TRPCError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to move this document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
findDocumentAuditLogs: authenticatedProcedure
|
||||
.input(ZFindDocumentAuditLogsQuerySchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
|
||||
@ -55,6 +55,7 @@ export const ZSetSettingsForDocumentMutationSchema = z.object({
|
||||
teamId: z.number().min(1).optional(),
|
||||
data: z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
externalId: z.string().nullish(),
|
||||
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
|
||||
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
|
||||
}),
|
||||
@ -168,3 +169,8 @@ export const ZDownloadAuditLogsMutationSchema = z.object({
|
||||
documentId: z.number(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export const ZMoveDocumentsToTeamSchema = z.object({
|
||||
documentId: z.number(),
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/de
|
||||
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
|
||||
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||
import { moveTemplateToTeam } from '@documenso/lib/server-only/template/move-template-to-team';
|
||||
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
|
||||
import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
@ -28,6 +29,7 @@ import {
|
||||
ZDuplicateTemplateMutationSchema,
|
||||
ZFindTemplatesQuerySchema,
|
||||
ZGetTemplateWithDetailsByIdQuerySchema,
|
||||
ZMoveTemplatesToTeamSchema,
|
||||
ZToggleTemplateDirectLinkMutationSchema,
|
||||
ZUpdateTemplateSettingsMutationSchema,
|
||||
} from './schema';
|
||||
@ -59,12 +61,18 @@ export const templateRouter = router({
|
||||
.input(ZCreateDocumentFromDirectTemplateMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { directRecipientEmail, directTemplateToken, signedFieldValues, templateUpdatedAt } =
|
||||
input;
|
||||
const {
|
||||
directRecipientName,
|
||||
directRecipientEmail,
|
||||
directTemplateToken,
|
||||
signedFieldValues,
|
||||
templateUpdatedAt,
|
||||
} = input;
|
||||
|
||||
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
|
||||
|
||||
return await createDocumentFromDirectTemplate({
|
||||
directRecipientName,
|
||||
directRecipientEmail,
|
||||
directTemplateToken,
|
||||
signedFieldValues,
|
||||
@ -290,4 +298,30 @@ export const templateRouter = router({
|
||||
throw AppError.parseErrorToTRPCError(error);
|
||||
}
|
||||
}),
|
||||
|
||||
moveTemplateToTeam: authenticatedProcedure
|
||||
.input(ZMoveTemplatesToTeamSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { templateId, teamId } = input;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
return await moveTemplateToTeam({
|
||||
templateId,
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof TRPCError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to move this template. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -17,6 +17,7 @@ export const ZCreateTemplateMutationSchema = z.object({
|
||||
});
|
||||
|
||||
export const ZCreateDocumentFromDirectTemplateMutationSchema = z.object({
|
||||
directRecipientName: z.string().optional(),
|
||||
directRecipientEmail: z.string().email(),
|
||||
directTemplateToken: z.string().min(1),
|
||||
signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema),
|
||||
@ -73,6 +74,7 @@ export const ZUpdateTemplateSettingsMutationSchema = z.object({
|
||||
teamId: z.number().min(1).optional(),
|
||||
data: z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
externalId: z.string().nullish(),
|
||||
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
|
||||
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
|
||||
publicTitle: z.string().trim().min(1).max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH).optional(),
|
||||
@ -109,6 +111,11 @@ export const ZGetTemplateWithDetailsByIdQuerySchema = z.object({
|
||||
id: z.number().min(1),
|
||||
});
|
||||
|
||||
export const ZMoveTemplatesToTeamSchema = z.object({
|
||||
templateId: z.number(),
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
|
||||
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||
typeof ZCreateDocumentFromTemplateMutationSchema
|
||||
@ -118,3 +125,4 @@ export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutati
|
||||
export type TGetTemplateWithDetailsByIdQuerySchema = z.infer<
|
||||
typeof ZGetTemplateWithDetailsByIdQuerySchema
|
||||
>;
|
||||
export type TMoveTemplatesToSchema = z.infer<typeof ZMoveTemplatesToTeamSchema>;
|
||||
|
||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@ -59,6 +59,7 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_SMTP_APIKEY?: string;
|
||||
|
||||
NEXT_PRIVATE_SMTP_SECURE?: string;
|
||||
NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS?: string;
|
||||
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
|
||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
|
||||
|
||||
@ -78,6 +78,7 @@ export const AddSettingsFormPartial = ({
|
||||
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||
defaultValues: {
|
||||
title: document.title,
|
||||
externalId: document.externalId || '',
|
||||
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
|
||||
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
|
||||
meta: {
|
||||
@ -183,6 +184,34 @@ export const AddSettingsFormPartial = ({
|
||||
|
||||
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-2 text-sm leading-relaxed">
|
||||
<div className="flex flex-col space-y-6 ">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
External ID{' '}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
Add an external ID to the document. This can be used to identify the
|
||||
document in external systems.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.dateFormat"
|
||||
|
||||
@ -21,6 +21,7 @@ export const ZMapNegativeOneToUndefinedSchema = z
|
||||
|
||||
export const ZAddSettingsFormSchema = z.object({
|
||||
title: z.string().trim().min(1, { message: "Title can't be empty" }),
|
||||
externalId: z.string().optional(),
|
||||
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
||||
ZDocumentAccessAuthTypesSchema.optional(),
|
||||
),
|
||||
|
||||
@ -79,6 +79,7 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
resolver: zodResolver(ZAddTemplateSettingsFormSchema),
|
||||
defaultValues: {
|
||||
title: template.title,
|
||||
externalId: template.externalId || undefined,
|
||||
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
|
||||
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
|
||||
meta: {
|
||||
@ -223,6 +224,34 @@ export const AddTemplateSettingsFormPartial = ({
|
||||
|
||||
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-4 text-sm leading-relaxed">
|
||||
<div className="flex flex-col space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
External ID{' '}
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
Add an external ID to the template. This can be used to identify in
|
||||
external systems.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.dateFormat"
|
||||
|
||||
@ -12,6 +12,7 @@ import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.
|
||||
|
||||
export const ZAddTemplateSettingsFormSchema = z.object({
|
||||
title: z.string().trim().min(1, { message: "Title can't be empty" }),
|
||||
externalId: z.string().optional(),
|
||||
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
||||
ZDocumentAccessAuthTypesSchema.optional(),
|
||||
),
|
||||
|
||||
@ -12,9 +12,12 @@
|
||||
]
|
||||
},
|
||||
"prebuild": {
|
||||
"cache": false,
|
||||
"dependsOn": [
|
||||
"^prebuild"
|
||||
],
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
@ -109,6 +112,7 @@
|
||||
"NEXT_PRIVATE_SMTP_APIKEY_USER",
|
||||
"NEXT_PRIVATE_SMTP_APIKEY",
|
||||
"NEXT_PRIVATE_SMTP_SECURE",
|
||||
"NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS",
|
||||
"NEXT_PRIVATE_SMTP_FROM_NAME",
|
||||
"NEXT_PRIVATE_SMTP_FROM_ADDRESS",
|
||||
"NEXT_PRIVATE_STRIPE_API_KEY",
|
||||
@ -137,4 +141,4 @@
|
||||
"E2E_TEST_AUTHENTICATE_USER_EMAIL",
|
||||
"E2E_TEST_AUTHENTICATE_USER_PASSWORD"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user