Compare commits

...

26 Commits

Author SHA1 Message Date
a3ee732a9b v1.6.0-rc.2 2024-07-15 10:50:49 +10:00
c3035dbd15 feat: add external id to documents and templates (#1227)
## Description

Adds the external ID column to documents and templates with an option to
configure it in the API or UI.

External ID's can be used to link a document or template to an external
system and identify them via webhooks, etc.
2024-07-13 16:45:09 +10:00
7f5b27372f feat: resend document via API (#1226)
Allow users to re-send documents via the API.
2024-07-12 21:03:52 +10:00
b0c081683f feat: allow anonymous smtp authentication (#1204)
Introduces the ability to use anonymous SMTP authentication where no username or password is provided.

Also introduces a new flag to disable TLS avoiding cases also where STARTTLS is used despite `secure` being
set to `false`
2024-07-09 10:39:59 +10:00
6b5e4da424 v1.6.0-rc.1 2024-07-05 14:24:40 +10:00
cb892bcbb2 feat: add user conversion count to admin panel (#1150)
Displays the count of users who signed up after signing at least one
document in the admin panel
2024-07-05 14:02:22 +10:00
a757ab2303 feat: move template to team (#1217)
Allows users to move templates from their personal account to a team
account.
2024-07-05 13:20:27 +10:00
2c320e8b92 fix: use team avatar everywhere (#1220)
Expands team avatar support across various components and pages of
the application.
2024-07-05 13:05:22 +10:00
2eee2b4d2a feat: send custom email to signers of direct template documents (#1215)
Introduces customization options for the document completion email
template to allow for custom email bodies and subjects for documents
created from direct templates.


## Testing Performed
- Verified correct rendering of custom email subject and body for direct
template documents
- Verified the all other completed email types are sent correctly
2024-07-05 13:03:22 +10:00
06b1d4835e Update signing-an-nda-faster-with-documenso.mdx
fix nda article after git switch
2024-07-04 13:57:57 +02:00
5f2dc1fe31 chore: final touches (#1219)
chore Top 3 Signing Efficiency Hacks for Freelancers

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a blog post titled "Top 3 Signing Efficiency Hacks for
Freelancers" with tips on streamlining contract signing using Documenso.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-07-04 13:55:08 +02:00
b3cb9a10be Chore/freelancer-3-hacks (#1218)
Top 3 Signing Efficiency Hacks for Freelancers article
2024-07-04 13:48:01 +02:00
7cff035f8a Chore/changelog-156 (#1214)
fix typo
2024-07-02 15:07:08 +02:00
7ac899eb8d chore: changelog 156 (#1213)
changelog for v 1.5.6

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
	- Display document creation time in the documents view.
	- Introduced direct template links for easier access.
- Added support for OpenID Connect (OIDC) for improved authentication
options.

- **Enhancements**
- Transitioned to a new rust-based library for signing documents,
enhancing performance and security.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-07-02 14:42:23 +02:00
92c09c5850 feat: move document to team (#1210)
Introduces a new dialog component allowing users to move documents
between teams with included audit logging.
2024-07-02 12:47:24 +10:00
90c43dcd0a Update announcing-profiles.mdx 2024-07-01 15:27:06 +02:00
48bf57d3aa chore: last text touches (#1212)
final touches for profiles announce
2024-07-01 14:59:33 +02:00
fc0c0a9754 chore: last text touches 2024-07-01 14:58:30 +02:00
455c3a63f9 Update announcing-profiles.mdx 2024-07-01 14:41:06 +02:00
780e91b055 Update signing-an-nda-faster-with-documenso.mdx 2024-07-01 14:33:00 +02:00
611e495e16 chore: profiles announce blogpost (#1211)
announcing profiles blogpost

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced "Documenso Profiles" that allow users to share digital
signature templates publicly.
- Recipients can sign documents directly via public links, streamlining
the signing process.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-07-01 14:29:37 +02:00
6361dd5fe5 Merge branch 'main' into chore/profiles-announce 2024-07-01 14:21:52 +02:00
e2674456d4 chore: last text touches 2024-07-01 14:16:29 +02:00
b8cc2a2e0f chore: typo 2024-06-26 13:22:43 +02:00
68f7a7f090 chore: announcing profiles, first draft 2024-06-07 13:39:08 +02:00
aa5beafe59 chore: profiles article file 2024-06-06 17:47:30 +02:00
72 changed files with 1106 additions and 72 deletions

View File

@ -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.

View 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, Im 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 documents owner becomes active. Booking links (e.g., Cal.com) and customer self-service portals (e.g., Stripe Billing Portal) are becoming the norm, so its 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).
Dont 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

View File

@ -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

View File

@ -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. Its 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

View File

@ -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.
---
´

View File

@ -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"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -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"
}
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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 />

View File

@ -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,
},

View File

@ -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}

View File

@ -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>

View 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>
);
};

View File

@ -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={

View File

@ -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,
},

View File

@ -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}

View 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>
);
};

View File

@ -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>

View File

@ -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) => {

View File

@ -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) ?? '',
)}

View File

@ -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>

View File

@ -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]}

View File

@ -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

View File

@ -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 && (

View File

@ -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
View File

@ -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": "*",

View File

@ -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",

View File

@ -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"
}
}
}

View File

@ -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',

View File

@ -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;

View File

@ -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(),

View File

@ -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"
}
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/ee",
"version": "1.0.0",
"version": "0.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "COMMERCIAL",

View File

@ -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,
});
};

View File

@ -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"
}
}
}

View File

@ -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">

View File

@ -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>

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/lib",
"version": "1.0.0",
"version": "0.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "MIT",

View File

@ -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),

View File

@ -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,

View File

@ -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,
],
},
},

View 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;
});
};

View File

@ -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: [

View File

@ -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,
},
});

View File

@ -15,6 +15,7 @@ export const getTeamInvitations = async ({ email }: GetTeamInvitationsOptions) =
id: true,
name: true,
url: true,
avatarImageId: true,
},
},
},

View File

@ -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,

View File

@ -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,

View 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;
});
};

View File

@ -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,

View 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>
>;

View File

@ -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,

View File

@ -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;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "externalId" TEXT;
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "externalId" TEXT;

View File

@ -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"
}
}
}

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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": {}
}
}

View File

@ -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 }) => {

View File

@ -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(),
});

View File

@ -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.',
});
}
}),
});

View File

@ -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>;

View File

@ -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;

View File

@ -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"

View File

@ -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(),
),

View File

@ -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"

View File

@ -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(),
),

View File

@ -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"
]
}
}