Compare commits

..

29 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan
5103477e7b fix: new get stats query 2024-12-11 00:40:42 +00:00
Ephraim Atta-Duncan
b19b57dbc9 fix: get stats query 2024-12-10 23:33:14 +00:00
Mythie
2b3ab9a3b7 fix: remove compiled translation files 2024-11-27 13:57:45 +11:00
Mythie
cac262fcea Merge branch 'main' into feat/delete-archive 2024-11-27 10:57:13 +11:00
Ephraim Atta-Duncan
0cd7c25718 fix: avoid delete duplicate button 2024-11-22 11:26:36 +00:00
Ephraim Atta-Duncan
79eec5f451 chore: translations 2024-11-22 11:24:26 +00:00
Ephraim Atta-Duncan
7ca0975650 fix: build errrors 2024-11-22 11:22:25 +00:00
Ephraim Atta-Duncan
870b3fb3d7 fix: match translations with main 2024-11-22 11:18:32 +00:00
Ephraim Atta-Duncan
43ea76fae3 fix: merge conflicts 2024-11-22 11:12:49 +00:00
Ephraim Atta-Duncan
2dd122aed3 fix: merge conflicts
againnnn
2024-10-16 15:41:41 +00:00
Ephraim Atta-Duncan
d3872e86f1 fix: merge conflicts 2024-10-16 15:39:59 +00:00
Ephraim Atta-Duncan
171398ae2d fix: merge conflicts 2024-09-21 09:07:16 +00:00
Ephraim Atta-Duncan
492350612e fix: wrong count on user 2024-09-21 08:19:24 +00:00
Ephraim Atta-Duncan
f9935adb57 fix: incorrect counts and query for teams 2024-09-20 06:28:08 +00:00
Mythie
d2b99303f9 fix: simplify deleted query 2024-08-21 13:13:03 +10:00
github-actions
d33bbe71e7 chore: extract translations 2024-08-21 01:44:57 +00:00
Lucas Smith
6bf62e0ecb Merge branch 'main' into feat/delete-archive 2024-08-21 11:44:05 +10:00
Ephraim Atta-Duncan
16527f01e7 fix: build errors 2024-07-29 15:18:54 +10:00
Ephraim Atta-Duncan
7f25508c3c chore: remove unused email template 2024-07-29 15:18:54 +10:00
Ephraim Atta-Duncan
754e9e6428 chore: refactor find documents 2024-07-29 15:18:54 +10:00
Ephraim Atta-Duncan
2837b178fb fix: soft delete a document when the owner deletes it 2024-07-29 15:18:54 +10:00
Ephraim Atta-Duncan
26ccdc1b23 fix: filter on recipients 2024-07-29 15:18:54 +10:00
Ephraim Atta-Duncan
ea63b45a13 fix: add recipients filter for bin 2024-07-29 15:18:54 +10:00
Ephraim Atta-Duncan
feef4b1a12 feat: restore deleted document 2024-07-29 15:18:52 +10:00
Ephraim Atta-Duncan
1a55f4253b fix: tab count for teams 2024-07-29 14:41:02 +10:00
Ephraim Atta-Duncan
8311e0cc29 fix: show correct tab count 2024-07-29 14:41:02 +10:00
Ephraim Atta-Duncan
a9adc36732 fix: date filter for deleted documents 2024-07-29 14:41:02 +10:00
Ephraim Atta-Duncan
73e375938c fix: show deleted counts on tab 2024-07-29 14:41:02 +10:00
Ephraim Atta-Duncan
c6393b7a9e feat: add bin tab to show soft deleted documents 2024-07-29 14:41:02 +10:00
137 changed files with 2257 additions and 3194 deletions

View File

@@ -139,6 +139,3 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# [[REDIS]] # [[REDIS]]
NEXT_PRIVATE_REDIS_URL= NEXT_PRIVATE_REDIS_URL=
NEXT_PRIVATE_REDIS_TOKEN= NEXT_PRIVATE_REDIS_TOKEN=
# [[LOGGER]]
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=

View File

@@ -1,9 +0,0 @@
{
"index": "Get Started",
"react": "React Integration",
"vue": "Vue Integration",
"svelte": "Svelte Integration",
"solid": "Solid Integration",
"preact": "Preact Integration",
"css-variables": "CSS Variables"
}

View File

@@ -1,120 +0,0 @@
---
title: CSS Variables
description: Learn about all available CSS variables for customizing your embedded signing experience
---
# CSS Variables
Platform customers have access to a comprehensive set of CSS variables that can be used to customize the appearance of the embedded signing experience. These variables control everything from colors to spacing and can be used to match your application's design system.
## Available Variables
### Colors
| Variable | Description | Default |
| ----------------------- | ---------------------------------- | -------------- |
| `background` | Base background color | System default |
| `foreground` | Base text color | System default |
| `muted` | Muted/subtle background color | System default |
| `mutedForeground` | Muted/subtle text color | System default |
| `popover` | Popover/dropdown background color | System default |
| `popoverForeground` | Popover/dropdown text color | System default |
| `card` | Card background color | System default |
| `cardBorder` | Card border color | System default |
| `cardBorderTint` | Card border tint/highlight color | System default |
| `cardForeground` | Card text color | System default |
| `fieldCard` | Field card background color | System default |
| `fieldCardBorder` | Field card border color | System default |
| `fieldCardForeground` | Field card text color | System default |
| `widget` | Widget background color | System default |
| `widgetForeground` | Widget text color | System default |
| `border` | Default border color | System default |
| `input` | Input field border color | System default |
| `primary` | Primary action/button color | System default |
| `primaryForeground` | Primary action/button text color | System default |
| `secondary` | Secondary action/button color | System default |
| `secondaryForeground` | Secondary action/button text color | System default |
| `accent` | Accent/highlight color | System default |
| `accentForeground` | Accent/highlight text color | System default |
| `destructive` | Destructive/danger action color | System default |
| `destructiveForeground` | Destructive/danger text color | System default |
| `ring` | Focus ring color | System default |
| `warning` | Warning/alert color | System default |
### Spacing and Layout
| Variable | Description | Default |
| -------- | ------------------------------- | -------------- |
| `radius` | Border radius size in REM units | System default |
## Usage Example
Here's how to use these variables in your embedding implementation:
```jsx
const cssVars = {
// Colors
background: '#ffffff',
foreground: '#000000',
primary: '#0000ff',
primaryForeground: '#ffffff',
accent: '#4f46e5',
destructive: '#ef4444',
// Spacing
radius: '0.5rem'
};
// React/Preact
<EmbedDirectTemplate
token={token}
cssVars={cssVars}
/>
// Vue
<EmbedDirectTemplate
:token="token"
:cssVars="cssVars"
/>
// Svelte
<EmbedDirectTemplate
{token}
cssVars={cssVars}
/>
// Solid
<EmbedDirectTemplate
token={token}
cssVars={cssVars}
/>
```
## Color Format
Colors can be specified in any valid CSS color format:
- Hexadecimal: `#ff0000`
- RGB: `rgb(255, 0, 0)`
- HSL: `hsl(0, 100%, 50%)`
- Named colors: `red`
The colors will be automatically converted to the appropriate format internally.
## Best Practices
1. **Maintain Contrast**: When customizing colors, ensure there's sufficient contrast between background and foreground colors for accessibility.
2. **Test Dark Mode**: If you haven't disabled dark mode, test your color variables in both light and dark modes.
3. **Use Your Brand Colors**: Align the primary and accent colors with your brand's color scheme for a cohesive look.
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
## Related
- [React Integration](/developers/embedding/react)
- [Vue Integration](/developers/embedding/vue)
- [Svelte Integration](/developers/embedding/svelte)
- [Solid Integration](/developers/embedding/solid)
- [Preact Integration](/developers/embedding/preact)

View File

@@ -11,11 +11,7 @@ Our embedding feature lets you integrate our document signing experience into yo
Embedding is currently available for all users on a **Teams Plan** and above, as well as **Early Adopter's** within a team (Early Adopters can create a team for free). Embedding is currently available for all users on a **Teams Plan** and above, as well as **Early Adopter's** within a team (Early Adopters can create a team for free).
Our **Platform Plan** offers enhanced customization features including: In the future, we will roll out a **Platform Plan** that will offer additional enhancements for embedding, including the option to remove Documenso branding for a more customized experience.
- Custom CSS and styling variables
- Dark mode controls
- The removal of Documenso branding from the embedding experience
## How Embedding Works ## How Embedding Works
@@ -26,49 +22,6 @@ Embedding with Documenso allows you to handle document signing in two main ways:
_For most use-cases we recommend using direct templates, however if you have a need for a more advanced integration, we are happy to help you get started._ _For most use-cases we recommend using direct templates, however if you have a need for a more advanced integration, we are happy to help you get started._
## Customization Options
### Styling and Theming
Platform customers have access to advanced styling options to customize the embedding experience:
1. **Custom CSS**: You can provide custom CSS to style the embedded component:
```jsx
<EmbedDirectTemplate
token={token}
css={`
.documenso-embed {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
`}
/>
```
2. **CSS Variables**: Fine-tune the appearance using CSS variables for colors, spacing, and more:
```jsx
<EmbedDirectTemplate
token={token}
cssVars={{
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
}}
/>
```
For a complete list of available CSS variables and their usage, see our [CSS Variables](/developers/embedding/css-variables) documentation.
3. **Dark Mode Control**: Disable dark mode if it doesn't match your application's theme:
```jsx
<EmbedDirectTemplate token={token} darkModeDisabled={true} />
```
These customization options are available for both Direct Templates and Signing Token embeds.
## Supported Frameworks ## Supported Frameworks
We support embedding across a range of popular JavaScript frameworks, including: We support embedding across a range of popular JavaScript frameworks, including:
@@ -167,11 +120,12 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases. If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
## Related ## Stay Tuned for the Platform Plan
- [React Integration](/developers/embedding/react) While embedding is already a powerful tool, we're working on a **Platform Plan** that will introduce even more functionality. This plan will offer:
- [Vue Integration](/developers/embedding/vue)
- [Svelte Integration](/developers/embedding/svelte) - Additional customization options
- [Solid Integration](/developers/embedding/solid) - The ability to remove Documenso branding
- [Preact Integration](/developers/embedding/preact) - Additional controls for the signing experience
- [CSS Variables](/developers/embedding/css-variables)
More details will be shared as we approach the release.

View File

@@ -44,9 +44,6 @@ const MyEmbeddingComponent = () => {
| email | string (optional) | The email the signer that will be used by default for signing | | email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications | | lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion | | externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
| css | string (optional) | Custom CSS to style the embedded component (Platform Plan only) |
| cssVars | object (optional) | CSS variables for customizing colors, spacing, etc. (Platform Plan only) |
| darkModeDisabled | boolean (optional) | Disable dark mode functionality (Platform Plan only) |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed | | onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed | | onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document | | onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
@@ -78,30 +75,3 @@ const MyEmbeddingComponent = () => {
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed | | onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed | | onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document | | onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
### Styling and Theming (Platform Plan)
Platform customers have access to advanced styling options:
```jsx
import { EmbedDirectTemplate } from '@documenso/embed-preact';
const MyEmbeddingComponent = () => {
const token = 'your-token';
const customCss = `
.documenso-embed {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
};
return (
<EmbedDirectTemplate token={token} css={customCss} cssVars={cssVars} darkModeDisabled={true} />
);
};
```

View File

@@ -44,9 +44,6 @@ const MyEmbeddingComponent = () => {
| email | string (optional) | The email the signer that will be used by default for signing | | email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications | | lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion | | externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
| css | string (optional) | Custom CSS to style the embedded component (Platform Plan only) |
| cssVars | object (optional) | CSS variables for customizing colors, spacing, etc. (Platform Plan only) |
| darkModeDisabled | boolean (optional) | Disable dark mode functionality (Platform Plan only) |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed | | onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed | | onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document | | onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
@@ -78,34 +75,3 @@ const MyEmbeddingComponent = () => {
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed | | onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed | | onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document | | onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
### Styling and Theming (Platform Plan)
Platform customers have access to advanced styling options:
```jsx
import { EmbedDirectTemplate } from '@documenso/embed-react';
const MyEmbeddingComponent = () => {
return (
<EmbedDirectTemplate
token="your-token"
// Custom CSS
css={`
.documenso-embed {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
`}
// CSS Variables
cssVars={{
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
}}
// Dark Mode Control
darkModeDisabled={true}
/>
);
};
```

View File

@@ -44,9 +44,6 @@ const MyEmbeddingComponent = () => {
| email | string (optional) | The email the signer that will be used by default for signing | | email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications | | lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion | | externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
| css | string (optional) | Custom CSS to style the embedded component (Platform Plan only) |
| cssVars | object (optional) | CSS variables for customizing colors, spacing, etc. (Platform Plan only) |
| darkModeDisabled | boolean (optional) | Disable dark mode functionality (Platform Plan only) |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed | | onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed | | onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document | | onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
@@ -78,30 +75,3 @@ const MyEmbeddingComponent = () => {
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed | | onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed | | onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document | | onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
### Styling and Theming (Platform Plan)
Platform customers have access to advanced styling options:
```jsx
import { EmbedDirectTemplate } from '@documenso/embed-solid';
const MyEmbeddingComponent = () => {
const token = 'your-token';
const customCss = `
.documenso-embed {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
};
return (
<EmbedDirectTemplate token={token} css={customCss} cssVars={cssVars} darkModeDisabled={true} />
);
};
```

View File

@@ -46,9 +46,6 @@ If you have a direct link template, you can simply provide the token for the tem
| email | string (optional) | The email the signer that will be used by default for signing | | email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications | | lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion | | externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
| css | string (optional) | Custom CSS to style the embedded component (Platform Plan only) |
| cssVars | object (optional) | CSS variables for customizing colors, spacing, etc. (Platform Plan only) |
| darkModeDisabled | boolean (optional) | Disable dark mode functionality (Platform Plan only) |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed | | onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed | | onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document | | onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
@@ -80,28 +77,3 @@ const MyEmbeddingComponent = () => {
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed | | onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed | | onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document | | onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
### Styling and Theming (Platform Plan)
Platform customers have access to advanced styling options:
```html
<script lang="ts">
import { EmbedDirectTemplate } from '@documenso/embed-svelte';
const token = 'your-token';
const customCss = `
.documenso-embed {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
};
</script>
<EmbedDirectTemplate {token} css="{customCss}" cssVars="{cssVars}" darkModeDisabled="{true}" />
```

View File

@@ -46,9 +46,6 @@ If you have a direct link template, you can simply provide the token for the tem
| email | string (optional) | The email the signer that will be used by default for signing | | email | string (optional) | The email the signer that will be used by default for signing |
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications | | lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
| externalId | string (optional) | The external ID to be used for the document that will be created upon completion | | externalId | string (optional) | The external ID to be used for the document that will be created upon completion |
| css | string (optional) | Custom CSS to style the embedded component (Platform Plan only) |
| cssVars | object (optional) | CSS variables for customizing colors, spacing, etc. (Platform Plan only) |
| darkModeDisabled | boolean (optional) | Disable dark mode functionality (Platform Plan only) |
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed | | onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed | | onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document | | onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
@@ -80,35 +77,3 @@ const MyEmbeddingComponent = () => {
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed | | onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed | | onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document | | onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
### Styling and Theming (Platform Plan)
Platform customers have access to advanced styling options:
```html
<script setup lang="ts">
import { EmbedDirectTemplate } from '@documenso/embed-vue';
const token = ref('your-token');
const customCss = `
.documenso-embed {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
`;
const cssVars = {
colorPrimary: '#0000FF',
colorBackground: '#F5F5F5',
borderRadius: '8px',
};
</script>
<template>
<EmbedDirectTemplate
:token="token"
:css="customCss"
:cssVars="cssVars"
:darkModeDisabled="true"
/>
</template>
```

View File

@@ -500,35 +500,8 @@ Now you can make a `POST` request to the `/api/v1/documents/{documentId}/fields`
value, this is the value that will be used to sign the field. value, this is the value that will be used to sign the field.
</Callout> </Callout>
<Callout type="warning">
It's important to pass the `type` in the `fieldMeta` property for the advanced fields. [Read more
here](#a-note-on-advanced-fields)
</Callout>
A successful request will return a JSON response with the newly added fields. The image below illustrates the fields added to the document via the API. A successful request will return a JSON response with the newly added fields. The image below illustrates the fields added to the document via the API.
![A screenshot of the document in the Documenso editor](/api-reference/fields-added-via-api.webp) ![A screenshot of the document in the Documenso editor](/api-reference/fields-added-via-api.webp)
</Steps> </Steps>
#### A Note on Advanced Fields
The advanced fields are: text, checkbox, radio, number, and select. Whenever you append any of these advanced fields to a document, you need to pass the `type` in the `fieldMeta` property:
```json
...
"fieldMeta": {
"type": "text",
}
...
```
Replace the `text` value with the corresponding field type:
- For the `TEXT` field it should be `text`.
- For the `CHECKBOX` field it should be `checkbox`.
- For the `RADIO` field it should be `radio`.
- For the `NUMBER` field it should be `number`.
- For the `SELECT` field it should be `select`. (check this before merge)
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.

View File

@@ -20,7 +20,6 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
- `document.opened` - `document.opened`
- `document.signed` - `document.signed`
- `document.completed` - `document.completed`
- `document.rejected`
## Create a webhook subscription ## Create a webhook subscription
@@ -37,7 +36,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo
To create a new webhook subscription, you need to provide the following information: To create a new webhook subscription, you need to provide the following information:
- Enter the webhook URL that will receive the event payload. - Enter the webhook URL that will receive the event payload.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`. - Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request. - Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp) ![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp)
@@ -54,55 +53,45 @@ You can edit or delete your webhook subscriptions by clicking the "**Edit**" or
The payload sent to the webhook URL contains the following fields: The payload sent to the webhook URL contains the following fields:
| Field | Type | Description | | Field | Type | Description |
| -------------------------------------------- | --------- | ----------------------------------------------------- | | -------------------------------------------- | --------- | ---------------------------------------------------- |
| `event` | string | The type of event that triggered the webhook. | | `event` | string | The type of event that triggered the webhook. |
| `payload.id` | number | The id of the document. | | `payload.id` | number | The id of the document. |
| `payload.externalId` | string? | External identifier for the document. | | `payload.userId` | number | The id of the user who owns the document. |
| `payload.userId` | number | The id of the user who owns the document. | | `payload.authOptions` | json? | Authentication options for the document. |
| `payload.authOptions` | json? | Authentication options for the document. | | `payload.formValues` | json? | Form values for the document. |
| `payload.formValues` | json? | Form values for the document. | | `payload.title` | string | The name of the document. |
| `payload.visibility` | string | Document visibility (e.g., EVERYONE). | | `payload.status` | string | The current status of the document. |
| `payload.title` | string | The title of the document. | | `payload.documentDataId` | string | The identifier for the document data. |
| `payload.status` | string | The current status of the document. | | `payload.createdAt` | datetime | The creation date and time of the document. |
| `payload.documentDataId` | string | The identifier for the document data. | | `payload.updatedAt` | datetime | The last update date and time of the document. |
| `payload.createdAt` | datetime | The creation date and time of the document. | | `payload.completedAt` | datetime? | The completion date and time of the document. |
| `payload.updatedAt` | datetime | The last update date and time of the document. | | `payload.deletedAt` | datetime? | The deletion date and time of the document. |
| `payload.completedAt` | datetime? | The completion date and time of the document. | | `payload.teamId` | number? | The id of the team. |
| `payload.deletedAt` | datetime? | The deletion date and time of the document. | | `payload.documentData.id` | string | The id of the document data. |
| `payload.teamId` | number? | The id of the team if document belongs to a team. | | `payload.documentData.type` | string | The type of the document data. |
| `payload.templateId` | number? | The id of the template if created from template. | | `payload.documentData.data` | string | The data of the document. |
| `payload.source` | string | The source of the document (e.g., DOCUMENT, TEMPLATE) | | `payload.documentData.initialData` | string | The initial data of the document. |
| `payload.documentMeta.id` | string | The id of the document metadata. | | `payload.Recipient[].id` | number | The id of the recipient. |
| `payload.documentMeta.subject` | string? | The subject of the document. | | `payload.Recipient[].documentId` | number? | The id the document associated with the recipient. |
| `payload.documentMeta.message` | string? | The message associated with the document. | | `payload.Recipient[].templateId` | number? | The template identifier for the recipient. |
| `payload.documentMeta.timezone` | string | The timezone setting for the document. | | `payload.Recipient[].email` | string | The email address of the recipient. |
| `payload.documentMeta.password` | string? | The password protection if set. | | `payload.Recipient[].name` | string | The name of the recipient. |
| `payload.documentMeta.dateFormat` | string | The date format used in the document. | | `payload.Recipient[].token` | string | The token associated with the recipient. |
| `payload.documentMeta.redirectUrl` | string? | The URL to redirect after signing. | | `payload.Recipient[].expired` | datetime? | The expiration status of the recipient. |
| `payload.documentMeta.signingOrder` | string | The signing order (e.g., PARALLEL, SEQUENTIAL). | | `payload.Recipient[].signedAt` | datetime? | The date and time the recipient signed the document. |
| `payload.documentMeta.typedSignatureEnabled` | boolean | Whether typed signatures are enabled. | | `payload.Recipient[].authOptions.accessAuth` | json? | Access authentication options. |
| `payload.documentMeta.language` | string | The language of the document. | | `payload.Recipient[].authOptions.actionAuth` | json? | Action authentication options. |
| `payload.documentMeta.distributionMethod` | string | The method of distributing the document. | | `payload.Recipient[].role` | string | The role of the recipient. |
| `payload.documentMeta.emailSettings` | json? | Email notification settings. | | `payload.Recipient[].readStatus` | string | The read status of the document by the recipient. |
| `payload.Recipient[].id` | number | The id of the recipient. | | `payload.Recipient[].signingStatus` | string | The signing status of the recipient. |
| `payload.Recipient[].documentId` | number? | The id of the document for this recipient. | | `payload.Recipient[].sendStatus` | string | The send status of the document to the recipient. |
| `payload.Recipient[].templateId` | number? | The template id if from a template. | | `createdAt` | datetime | The creation date and time of the webhook event. |
| `payload.Recipient[].email` | string | The email address of the recipient. | | `webhookEndpoint` | string | The endpoint URL where the webhook is sent. |
| `payload.Recipient[].name` | string | The name of the recipient. |
| `payload.Recipient[].token` | string | The unique token for this recipient. | ## Webhook event payload example
| `payload.Recipient[].documentDeletedAt` | datetime? | When the document was deleted for this recipient. |
| `payload.Recipient[].expired` | datetime? | When the recipient's access expired. | When an event that you have subscribed to occurs, Documenso will send a POST request to the specified webhook URL with a payload containing information about the event.
| `payload.Recipient[].signedAt` | datetime? | When the recipient signed the document. |
| `payload.Recipient[].authOptions` | json? | Authentication options for this recipient. |
| `payload.Recipient[].signingOrder` | number? | The order in which this recipient should sign. |
| `payload.Recipient[].rejectionReason` | string? | The reason if the recipient rejected the document. |
| `payload.Recipient[].role` | string | The role of the recipient (e.g., SIGNER, VIEWER). |
| `payload.Recipient[].readStatus` | string | Whether the recipient has read the document. |
| `payload.Recipient[].signingStatus` | string | The signing status of this recipient. |
| `payload.Recipient[].sendStatus` | string | The sending status for this recipient. |
| `createdAt` | datetime | The creation date and time of the webhook event. |
| `webhookEndpoint` | string | The endpoint URL where the webhook is sent. |
## Example payloads ## Example payloads
@@ -115,11 +104,9 @@ Example payload for the `document.created` event:
"event": "DOCUMENT_CREATED", "event": "DOCUMENT_CREATED",
"payload": { "payload": {
"id": 10, "id": 10,
"externalId": null,
"userId": 1, "userId": 1,
"authOptions": null, "authOptions": null,
"formValues": null, "formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf", "title": "documenso.pdf",
"status": "DRAFT", "status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0", "documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
@@ -127,43 +114,7 @@ Example payload for the `document.created` event:
"updatedAt": "2024-04-22T11:44:43.341Z", "updatedAt": "2024-04-22T11:44:43.341Z",
"completedAt": null, "completedAt": null,
"deletedAt": null, "deletedAt": null,
"teamId": null, "teamId": null
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "John Doe",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
}, },
"createdAt": "2024-04-22T11:44:44.779Z", "createdAt": "2024-04-22T11:44:44.779Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook" "webhookEndpoint": "https://mywebhooksite.com/mywebhook"
@@ -177,11 +128,9 @@ Example payload for the `document.sent` event:
"event": "DOCUMENT_SENT", "event": "DOCUMENT_SENT",
"payload": { "payload": {
"id": 10, "id": 10,
"externalId": null,
"userId": 1, "userId": 1,
"authOptions": null, "authOptions": null,
"formValues": null, "formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf", "title": "documenso.pdf",
"status": "PENDING", "status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0", "documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
@@ -190,22 +139,6 @@ Example payload for the `document.sent` event:
"completedAt": null, "completedAt": null,
"deletedAt": null, "deletedAt": null,
"teamId": null, "teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [ "Recipient": [
{ {
"id": 52, "id": 52,
@@ -214,12 +147,12 @@ Example payload for the `document.sent` event:
"email": "signer2@documenso.com", "email": "signer2@documenso.com",
"name": "Signer 2", "name": "Signer 2",
"token": "vbT8hi3jKQmrFP_LN1WcS", "token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null, "expired": null,
"signedAt": null, "signedAt": null,
"authOptions": null, "authOptions": {
"signingOrder": 1, "accessAuth": null,
"rejectionReason": null, "actionAuth": null
},
"role": "VIEWER", "role": "VIEWER",
"readStatus": "NOT_OPENED", "readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED", "signingStatus": "NOT_SIGNED",
@@ -232,12 +165,12 @@ Example payload for the `document.sent` event:
"email": "signer1@documenso.com", "email": "signer1@documenso.com",
"name": "Signer 1", "name": "Signer 1",
"token": "HkrptwS42ZBXdRKj1TyUo", "token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expired": null, "expired": null,
"signedAt": null, "signedAt": null,
"authOptions": null, "authOptions": {
"signingOrder": 2, "accessAuth": null,
"rejectionReason": null, "actionAuth": null
},
"role": "SIGNER", "role": "SIGNER",
"readStatus": "NOT_OPENED", "readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED", "signingStatus": "NOT_SIGNED",
@@ -257,11 +190,9 @@ Example payload for the `document.opened` event:
"event": "DOCUMENT_OPENED", "event": "DOCUMENT_OPENED",
"payload": { "payload": {
"id": 10, "id": 10,
"externalId": null,
"userId": 1, "userId": 1,
"authOptions": null, "authOptions": null,
"formValues": null, "formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf", "title": "documenso.pdf",
"status": "PENDING", "status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0", "documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
@@ -270,22 +201,6 @@ Example payload for the `document.opened` event:
"completedAt": null, "completedAt": null,
"deletedAt": null, "deletedAt": null,
"teamId": null, "teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [ "Recipient": [
{ {
"id": 52, "id": 52,
@@ -294,18 +209,24 @@ Example payload for the `document.opened` event:
"email": "signer2@documenso.com", "email": "signer2@documenso.com",
"name": "Signer 2", "name": "Signer 2",
"token": "vbT8hi3jKQmrFP_LN1WcS", "token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null, "expired": null,
"signedAt": null, "signedAt": null,
"authOptions": null, "authOptions": {
"signingOrder": 1, "accessAuth": null,
"rejectionReason": null, "actionAuth": null
},
"role": "VIEWER", "role": "VIEWER",
"readStatus": "OPENED", "readStatus": "OPENED",
"signingStatus": "NOT_SIGNED", "signingStatus": "NOT_SIGNED",
"sendStatus": "SENT" "sendStatus": "SENT"
} }
] ],
"documentData": {
"id": "hs8qz1ktr9204jn7mg6c5dxy0",
"type": "S3_PATH",
"data": "9753/xzqrshtlpokm/documenso.pdf",
"initialData": "9753/xzqrshtlpokm/documenso.pdf"
}
}, },
"createdAt": "2024-04-22T11:50:26.174Z", "createdAt": "2024-04-22T11:50:26.174Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook" "webhookEndpoint": "https://mywebhooksite.com/mywebhook"
@@ -319,11 +240,9 @@ Example payload for the `document.signed` event:
"event": "DOCUMENT_SIGNED", "event": "DOCUMENT_SIGNED",
"payload": { "payload": {
"id": 10, "id": 10,
"externalId": null,
"userId": 1, "userId": 1,
"authOptions": null, "authOptions": null,
"formValues": null, "formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf", "title": "documenso.pdf",
"status": "COMPLETED", "status": "COMPLETED",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0", "documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
@@ -332,22 +251,6 @@ Example payload for the `document.signed` event:
"completedAt": "2024-04-22T11:52:05.707Z", "completedAt": "2024-04-22T11:52:05.707Z",
"deletedAt": null, "deletedAt": null,
"teamId": null, "teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [ "Recipient": [
{ {
"id": 51, "id": 51,
@@ -356,15 +259,12 @@ Example payload for the `document.signed` event:
"email": "signer1@documenso.com", "email": "signer1@documenso.com",
"name": "Signer 1", "name": "Signer 1",
"token": "HkrptwS42ZBXdRKj1TyUo", "token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expired": null, "expired": null,
"signedAt": "2024-04-22T11:52:05.688Z", "signedAt": "2024-04-22T11:52:05.688Z",
"authOptions": { "authOptions": {
"accessAuth": null, "accessAuth": null,
"actionAuth": null "actionAuth": null
}, },
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER", "role": "SIGNER",
"readStatus": "OPENED", "readStatus": "OPENED",
"signingStatus": "SIGNED", "signingStatus": "SIGNED",
@@ -384,11 +284,9 @@ Example payload for the `document.completed` event:
"event": "DOCUMENT_COMPLETED", "event": "DOCUMENT_COMPLETED",
"payload": { "payload": {
"id": 10, "id": 10,
"externalId": null,
"userId": 1, "userId": 1,
"authOptions": null, "authOptions": null,
"formValues": null, "formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf", "title": "documenso.pdf",
"status": "COMPLETED", "status": "COMPLETED",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0", "documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
@@ -397,21 +295,11 @@ Example payload for the `document.completed` event:
"completedAt": "2024-04-22T11:52:05.707Z", "completedAt": "2024-04-22T11:52:05.707Z",
"deletedAt": null, "deletedAt": null,
"teamId": null, "teamId": null,
"templateId": null, "documentData": {
"source": "DOCUMENT", "id": "hs8qz1ktr9204jn7mg6c5dxy0",
"documentMeta": { "type": "S3_PATH",
"id": "doc_meta_123", "data": "bk9p1h7x0s3m/documenso-signed.pdf",
"subject": "Please sign this document", "initialData": "9753/xzqrshtlpokm/documenso.pdf"
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
}, },
"Recipient": [ "Recipient": [
{ {
@@ -421,15 +309,12 @@ Example payload for the `document.completed` event:
"email": "signer2@documenso.com", "email": "signer2@documenso.com",
"name": "Signer 2", "name": "Signer 2",
"token": "vbT8hi3jKQmrFP_LN1WcS", "token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null, "expired": null,
"signedAt": "2024-04-22T11:51:10.055Z", "signedAt": "2024-04-22T11:51:10.055Z",
"authOptions": { "authOptions": {
"accessAuth": null, "accessAuth": null,
"actionAuth": null "actionAuth": null
}, },
"signingOrder": 1,
"rejectionReason": null,
"role": "VIEWER", "role": "VIEWER",
"readStatus": "OPENED", "readStatus": "OPENED",
"signingStatus": "SIGNED", "signingStatus": "SIGNED",
@@ -442,15 +327,12 @@ Example payload for the `document.completed` event:
"email": "signer1@documenso.com", "email": "signer1@documenso.com",
"name": "Signer 1", "name": "Signer 1",
"token": "HkrptwS42ZBXdRKj1TyUo", "token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expired": null, "expired": null,
"signedAt": "2024-04-22T11:52:05.688Z", "signedAt": "2024-04-22T11:52:05.688Z",
"authOptions": { "authOptions": {
"accessAuth": null, "accessAuth": null,
"actionAuth": null "actionAuth": null
}, },
"signingOrder": 2,
"rejectionReason": null,
"role": "SIGNER", "role": "SIGNER",
"readStatus": "OPENED", "readStatus": "OPENED",
"signingStatus": "SIGNED", "signingStatus": "SIGNED",
@@ -463,71 +345,6 @@ Example payload for the `document.completed` event:
} }
``` ```
Example payload for the `document.rejected` event:
```json
{
"event": "DOCUMENT_REJECTED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": "2024-04-22T11:48:07.569Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": "I do not agree with the terms",
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "REJECTED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:48:07.945Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
## Availability ## Availability
Webhooks are available to individual users and teams. Webhooks are available to individual users and teams.

View File

@@ -10,6 +10,7 @@
"signing-documents": "Signing Documents", "signing-documents": "Signing Documents",
"templates": "Templates", "templates": "Templates",
"direct-links": "Direct Signing Links", "direct-links": "Direct Signing Links",
"document-visibility": "Document Visibility",
"teams": "Teams", "teams": "Teams",
"-- Legal Overview": { "-- Legal Overview": {
"type": "separator", "type": "separator",

View File

@@ -1,6 +1,5 @@
{ {
"preferences": "Preferences", "general-settings": "General Settings",
"document-visibility": "Document Visibility", "document-visibility": "Document Visibility",
"sender-details": "Email Sender Details", "sender-details": "Email Sender Details"
"branding-preferences": "Branding Preferences"
} }

View File

@@ -1,16 +0,0 @@
---
title: Branding Preferences
description: Learn how to set the branding preferences for your team account.
---
# Branding Preferences
You can set the branding preferences for your team account by going to the **Branding Preferences** tab in the team's settings dashboard.
![A screenshot of the team's branding preferences page](/teams/team-branding-preferences.webp)
On this page, you can:
- **Upload a Logo** - Upload your team's logo to be displayed instead of the default Documenso logo.
- **Set the Brand Website** - Enter the URL of your team's website to be displayed in the email communications sent by the team.
- **Add Additional Brand Details** - You can add additional information to display at the bottom of the emails sent by the team. This can include contact information, social media links, and other relevant details.

View File

@@ -13,9 +13,9 @@ The default document visibility option allows you to control who can view and ac
- **Managers and above** - The document is visible to team members with the role of _Manager or above_ and _Admin_. - **Managers and above** - The document is visible to team members with the role of _Manager or above_ and _Admin_.
- **Admin only** - The document is only visible to the team's admins. - **Admin only** - The document is only visible to the team's admins.
![A screenshot of the document visibility selector from the team's global preferences page](/teams/team-preferences-document-visibility.webp) ![A screenshot of the document visibility selector from the team's general settings page](/teams/team-general-settings-document-visibility-select.webp)
The default document visibility is set to "_EVERYONE_" by default. You can change this setting by going to the [team's general preferences page](/users/teams/preferences) and selecting a different visibility option. The default document visibility is set to "_EVERYONE_" by default. You can change this setting by going to the [team's general settings page](/users/teams/general-settings) and selecting a different visibility option.
<Callout type="warning"> <Callout type="warning">
If the team member uploading the document has a role lower than the default document visibility, If the team member uploading the document has a role lower than the default document visibility,

View File

@@ -0,0 +1,15 @@
---
title: General Settings
description: Learn how to manage your team's General settings.
---
# General Settings
You can manage your team's general settings by clicking on the **General Settings** tab in the team's settings dashboard.
![A screenshot of team's General settings page](/teams/team-general-settings.webp)
The general settings page allows you to update the following settings:
- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/teams/document-visibility).
- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. Learn more about [sender details](/users/teams/sender-details).

View File

@@ -1,19 +0,0 @@
---
title: Preferences
description: Learn how to manage your team's global preferences.
---
# Preferences
You can manage your team's global preferences by clicking on the **Preferences** tab in the team's settings dashboard.
![A screenshot of the team's global preferences page](/teams/team-preferences.webp)
The preferences page allows you to update the following settings:
- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/teams/document-visibility).
- **Default Document Language** - This setting allows you to set the default language for the documents uploaded in the team account. The default language is used as the default language in the email communications with the document recipients. You can change the language for individual documents when uploading them.
- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. Learn more about [sender details](/users/teams/sender-details).
- **Typed Signature** - It controls whether the document recipients can sign the documents with a typed signature or not. If enabled, the recipients can sign the document using either a drawn or a typed signature. If disabled, the recipients can only sign the documents usign a drawn signature. This setting can also be changed for individual documents when uploading them.
- **Include the Signing Certificate** - This setting controls whether the signing certificate should be included in the signed documents. If enabled, the signing certificate is included in the signed documents. If disabled, the signing certificate is not included in the signed documents. Regardless of this setting, the signing certificate is always available in the document's audit log page.
- **Branding Preferences** - Set the branding preferences and defaults for the team account. Learn more about [branding preferences](/users/teams/branding-preferences).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

View File

@@ -1,6 +1,6 @@
{ {
"name": "@documenso/marketing", "name": "@documenso/marketing",
"version": "1.8.1", "version": "1.8.1-rc.1",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {

View File

@@ -2,8 +2,8 @@ declare namespace NodeJS {
export interface ProcessEnv { export interface ProcessEnv {
NEXT_PUBLIC_WEBAPP_URL?: string; NEXT_PUBLIC_WEBAPP_URL?: string;
NEXT_PUBLIC_MARKETING_URL?: string; NEXT_PUBLIC_MARKETING_URL?: string;
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?:string; NEXT_PRIVATE_INTERNAL_WEBAPP_URL?: string;
NEXT_PRIVATE_DATABASE_URL: string; NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@documenso/web", "name": "@documenso/web",
"version": "1.8.1", "version": "1.8.1-rc.1",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {

View File

@@ -2,7 +2,7 @@ declare namespace NodeJS {
export interface ProcessEnv { export interface ProcessEnv {
NEXT_PUBLIC_WEBAPP_URL?: string; NEXT_PUBLIC_WEBAPP_URL?: string;
NEXT_PUBLIC_MARKETING_URL?: string; NEXT_PUBLIC_MARKETING_URL?: string;
NEXT_PRIVATE_INTERNAL_WEBAPP_URL?:string; NEXT_PRIVATE_INTERNAL_WEBAPP_URL?: string;
NEXT_PRIVATE_DATABASE_URL: string; NEXT_PRIVATE_DATABASE_URL: string;

View File

@@ -1,169 +0,0 @@
'use client';
import { useEffect, useMemo, useState, useTransition } from 'react';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ChevronDownIcon as CaretSortIcon, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
export type SigningVolume = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
};
type LeaderboardTableProps = {
signingVolume: SigningVolume[];
totalPages: number;
perPage: number;
page: number;
sortBy: 'name' | 'createdAt' | 'signingVolume';
sortOrder: 'asc' | 'desc';
};
export const LeaderboardTable = ({
signingVolume,
totalPages,
perPage,
page,
sortBy,
sortOrder,
}: LeaderboardTableProps) => {
const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const [searchString, setSearchString] = useState('');
const debouncedSearchString = useDebouncedValue(searchString, 1000);
const columns = useMemo(() => {
return [
{
header: () => (
<div
className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('name')}
>
{_(msg`Name`)}
<CaretSortIcon className="ml-2 h-4 w-4" />
</div>
),
accessorKey: 'name',
cell: ({ row }) => {
return (
<div>
<a
className="text-primary underline"
href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`}
target="_blank"
>
{row.getValue('name')}
</a>
</div>
);
},
size: 250,
},
{
header: () => (
<div
className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('signingVolume')}
>
{_(msg`Signing Volume`)}
<CaretSortIcon className="ml-2 h-4 w-4" />
</div>
),
accessorKey: 'signingVolume',
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
},
{
header: () => {
return (
<div
className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('createdAt')}
>
{_(msg`Created`)}
<CaretSortIcon className="ml-2 h-4 w-4" />
</div>
);
},
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
] satisfies DataTableColumnDef<SigningVolume>[];
}, [sortOrder]);
useEffect(() => {
startTransition(() => {
updateSearchParams({
search: debouncedSearchString,
page: 1,
perPage,
sortBy,
sortOrder,
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchString]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchString(e.target.value);
};
const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => {
startTransition(() => {
updateSearchParams({
sortBy: column,
sortOrder: sortOrder === 'asc' ? 'desc' : 'asc',
});
});
};
return (
<div className="relative">
<Input
className="my-6 flex flex-row gap-4"
type="text"
placeholder={_(msg`Search by name or email`)}
value={searchString}
onChange={handleChange}
/>
<DataTable
columns={columns}
data={signingVolume}
perPage={perPage}
currentPage={page}
totalPages={totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@@ -1,25 +0,0 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
type SearchOptions = {
search: string;
page: number;
perPage: number;
sortBy: 'name' | 'createdAt' | 'signingVolume';
sortOrder: 'asc' | 'desc';
};
export async function search({ search, page, perPage, sortBy, sortOrder }: SearchOptions) {
const { user } = await getRequiredServerComponentSession();
if (!isAdmin(user)) {
throw new Error('Unauthorized');
}
const results = await getSigningVolume({ search, page, perPage, sortBy, sortOrder });
return results;
}

View File

@@ -1,60 +0,0 @@
import { Trans } from '@lingui/macro';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { LeaderboardTable } from './data-table-leaderboard';
import { search } from './fetch-leaderboard.actions';
type AdminLeaderboardProps = {
searchParams?: {
search?: string;
page?: number;
perPage?: number;
sortBy?: 'name' | 'createdAt' | 'signingVolume';
sortOrder?: 'asc' | 'desc';
};
};
export default async function Leaderboard({ searchParams = {} }: AdminLeaderboardProps) {
await setupI18nSSR();
const { user } = await getRequiredServerComponentSession();
if (!isAdmin(user)) {
throw new Error('Unauthorized');
}
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
const searchString = searchParams.search || '';
const sortBy = searchParams.sortBy || 'signingVolume';
const sortOrder = searchParams.sortOrder || 'desc';
const { leaderboard: signingVolume, totalPages } = await search({
search: searchString,
page,
perPage,
sortBy,
sortOrder,
});
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
<div className="mt-8">
<LeaderboardTable
signingVolume={signingVolume}
totalPages={totalPages}
page={page}
perPage={perPage}
sortBy={sortBy}
sortOrder={sortOrder}
/>
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react'; import { BarChart3, FileStack, Settings, Users, Wallet2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -80,20 +80,6 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
</Link> </Link>
</Button> </Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
)}
asChild
>
<Link href="/admin/leaderboard">
<Trophy className="mr-2 h-5 w-5" />
<Trans>Leaderboard</Trans>
</Link>
</Button>
<Button <Button
variant="ghost" variant="ghost"
className={cn( className={cn(

View File

@@ -26,7 +26,7 @@ export const DocumentPageViewInformation = ({
const { _, i18n } = useLingui(); const { _, i18n } = useLingui();
const documentInformation = useMemo(() => { const documentInformation = useMemo(() => {
return [ const info = [
{ {
description: msg`Uploaded by`, description: msg`Uploaded by`,
value: userId === document.userId ? _(msg`You`) : document.User.name ?? document.User.email, value: userId === document.userId ? _(msg`You`) : document.User.name ?? document.User.email,
@@ -44,8 +44,20 @@ export const DocumentPageViewInformation = ({
.toRelative(), .toRelative(),
}, },
]; ];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, document, userId]); if (document.deletedAt) {
info.push({
description: msg`Deleted`,
value:
document.deletedAt &&
DateTime.fromJSDate(document.deletedAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toFormat('MMMM d, yyyy'),
});
}
return info;
}, [isMounted, document, i18n.locales?.[0] || i18n.locale, userId]);
return ( return (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border"> <section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">

View File

@@ -221,7 +221,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<DocumentPageViewDropdown document={documentWithRecipients} team={team} /> <DocumentPageViewDropdown document={documentWithRecipients} team={team} />
</div> </div>
<p className="text-muted-foreground mt-2 px-4 text-sm "> <p className="text-muted-foreground mt-2 px-4 text-sm">
{match(document.status) {match(document.status)
.with(DocumentStatus.COMPLETED, () => ( .with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans> <Trans>This document has been signed by all recipients</Trans>

View File

@@ -7,6 +7,7 @@ import Link from 'next/link';
import { Trans, msg } from '@lingui/macro'; import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { import {
ArchiveRestore,
CheckCircle, CheckCircle,
Copy, Copy,
Download, Download,
@@ -23,8 +24,8 @@ import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@@ -43,6 +44,7 @@ import { ResendDocumentActionItem } from './_action-items/resend-document';
import { DeleteDocumentDialog } from './delete-document-dialog'; import { DeleteDocumentDialog } from './delete-document-dialog';
import { DuplicateDocumentDialog } from './duplicate-document-dialog'; import { DuplicateDocumentDialog } from './duplicate-document-dialog';
import { MoveDocumentDialog } from './move-document-dialog'; import { MoveDocumentDialog } from './move-document-dialog';
import { RestoreDocumentDialog } from './restore-document-dialog';
export type DataTableActionDropdownProps = { export type DataTableActionDropdownProps = {
row: Document & { row: Document & {
@@ -61,6 +63,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false); const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
const [isRestoreDialogOpen, setRestoreDialogOpen] = useState(false);
if (!session) { if (!session) {
return null; return null;
@@ -76,6 +79,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const isDeletedDocument = row.deletedAt !== null;
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@@ -181,13 +185,23 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
Void Void
</DropdownMenuItem> */} </DropdownMenuItem> */}
<DropdownMenuItem {isDeletedDocument ? (
onClick={() => setDeleteDialogOpen(true)} <DropdownMenuItem
disabled={Boolean(!canManageDocument && team?.teamEmail)} onClick={() => setRestoreDialogOpen(true)}
> disabled={Boolean(!canManageDocument)}
<Trash2 className="mr-2 h-4 w-4" /> >
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)} <ArchiveRestore className="mr-2 h-4 w-4" />
</DropdownMenuItem> Restore
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? 'Delete' : 'Hide'}
</DropdownMenuItem>
)}
<DropdownMenuLabel> <DropdownMenuLabel>
<Trans>Share</Trans> <Trans>Share</Trans>
@@ -239,6 +253,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
onOpenChange={setMoveDialogOpen} onOpenChange={setMoveDialogOpen}
/> />
<RestoreDocumentDialog
id={row.id}
status={row.status}
documentTitle={row.title}
open={isRestoreDialogOpen}
onOpenChange={setRestoreDialogOpen}
teamId={team?.id}
canManageDocument={canManageDocument}
/>
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DuplicateDocumentDialog <DuplicateDocumentDialog
id={row.id} id={row.id}

View File

@@ -4,10 +4,10 @@ import { Trans } from '@lingui/macro';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; 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'; import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats'; import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats-new';
import { getStats } from '@documenso/lib/server-only/document/get-stats-new';
import { parseToIntegerArray } from '@documenso/lib/utils/params'; import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team, TeamEmail, TeamMemberRole } from '@documenso/prisma/client'; import type { Team, TeamEmail, TeamMemberRole } from '@documenso/prisma/client';
@@ -35,7 +35,7 @@ export interface DocumentsPageViewProps {
senderIds?: string; senderIds?: string;
search?: string; search?: string;
}; };
team?: Team & { teamEmail?: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } }; team?: Team & { teamEmail: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } };
} }
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => { export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
@@ -50,25 +50,14 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const currentTeam = team const currentTeam = team
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email } ? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
: undefined; : undefined;
const currentTeamMemberRole = team?.currentTeamMember?.role;
const getStatOptions: GetStatsInput = { const getStatOptions: GetStatsInput = {
user, user,
period, period,
team,
search, search,
}; };
if (team) {
getStatOptions.team = {
teamId: team.id,
teamEmail: team.teamEmail?.email,
senderIds,
currentTeamMemberRole,
currentUserEmail: user.email,
userId: user.id,
};
}
const stats = await getStats(getStatOptions); const stats = await getStats(getStatOptions);
const results = await findDocuments({ const results = await findDocuments({
@@ -128,6 +117,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
ExtendedDocumentStatus.PENDING, ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED, ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT, ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.BIN,
ExtendedDocumentStatus.ALL, ExtendedDocumentStatus.ALL,
].map((value) => ( ].map((value) => (
<TabsTrigger <TabsTrigger

View File

@@ -1,6 +1,6 @@
import { msg } from '@lingui/macro'; import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2 } from 'lucide-react'; import { Bird, CheckCircle2, Trash } from 'lucide-react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -30,6 +30,11 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
message: msg`You have not yet created or received any documents. To create a document please upload one.`, message: msg`You have not yet created or received any documents. To create a document please upload one.`,
icon: Bird, icon: Bird,
})) }))
.with(ExtendedDocumentStatus.BIN, () => ({
title: msg`No documents in the bin`,
message: msg`There are no documents in the bin.`,
icon: Trash,
}))
.otherwise(() => ({ .otherwise(() => ({
title: msg`Nothing to do`, title: msg`Nothing to do`,
message: msg`All documents have been processed. Any new documents that are sent or received will show here.`, message: msg`All documents have been processed. Any new documents that are sent or received will show here.`,
@@ -42,7 +47,6 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
data-testid="empty-document-state" data-testid="empty-document-state"
> >
<Icon className="h-12 w-12" strokeWidth={1.5} /> <Icon className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold">{_(title)}</h3> <h3 className="text-lg font-semibold">{_(title)}</h3>

View File

@@ -0,0 +1,90 @@
import { useRouter } from 'next/navigation';
import type { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@documenso/ui/primitives/alert-dialog';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
type RestoreDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
status: DocumentStatus;
documentTitle: string;
teamId?: number;
canManageDocument: boolean;
};
export function RestoreDocumentDialog({
id,
teamId,
open,
onOpenChange,
documentTitle,
canManageDocument,
}: RestoreDocumentDialogProps) {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: restoreDocument, isLoading } =
trpcReact.document.restoreDocument.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Document restored',
description: `"${documentTitle}" has been successfully restored`,
duration: 5000,
});
onOpenChange(false);
},
});
const onRestore = async () => {
try {
await restoreDocument({ id, teamId });
} catch {
toast({
title: 'Something went wrong',
description: 'This document could not be restored at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
return (
<AlertDialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
You are about to restore the document <strong>"{documentTitle}"</strong>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
type="button"
loading={isLoading}
onClick={onRestore}
disabled={!canManageDocument}
>
Restore
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -41,7 +41,7 @@ export default async function BillingSettingsPage() {
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([ const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }), getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ plans: [STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.PLATFORM] }), getPricesByInterval({ plan: STRIPE_PLAN_TYPE.REGULAR }),
getPrimaryAccountPlanPrices(), getPrimaryAccountPlanPrices(),
]); ]);

View File

@@ -53,17 +53,6 @@ export const SigningPageView = ({
}: SigningPageViewProps) => { }: SigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
const shouldUseTeamDetails =
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
let senderName = document.User.name ?? '';
let senderEmail = `(${document.User.email})`;
if (shouldUseTeamDetails) {
senderName = document.team?.name ?? '';
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
}
return ( return (
<div className="mx-auto w-full max-w-screen-xl"> <div className="mx-auto w-full max-w-screen-xl">
<h1 <h1
@@ -74,41 +63,27 @@ export const SigningPageView = ({
</h1> </h1>
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6"> <div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
<div className="max-w-[50ch]"> <div>
<span className="text-muted-foreground truncate" title={senderName}> <p
{senderName} {senderEmail} className="text-muted-foreground truncate"
</span>{' '} title={document.User.name ? document.User.name : ''}
<span className="text-muted-foreground"> >
{document.User.name}
</p>
<p className="text-muted-foreground">
{match(recipient.role) {match(recipient.role)
.with(RecipientRole.VIEWER, () => .with(RecipientRole.VIEWER, () => (
document.teamId && !shouldUseTeamDetails ? ( <Trans>({document.User.email}) has invited you to view this document</Trans>
<Trans> ))
on behalf of "{document.team?.name}" has invited you to view this document .with(RecipientRole.SIGNER, () => (
</Trans> <Trans>({document.User.email}) has invited you to sign this document</Trans>
) : ( ))
<Trans>has invited you to view this document</Trans> .with(RecipientRole.APPROVER, () => (
), <Trans>({document.User.email}) has invited you to approve this document</Trans>
) ))
.with(RecipientRole.SIGNER, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to sign this document
</Trans>
) : (
<Trans>has invited you to sign this document</Trans>
),
)
.with(RecipientRole.APPROVER, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to approve this document
</Trans>
) : (
<Trans>has invited you to approve this document</Trans>
),
)
.otherwise(() => null)} .otherwise(() => null)}
</span> </p>
</div> </div>
<RejectDocumentDialog document={document} token={recipient.token} /> <RejectDocumentDialog document={document} token={recipient.token} />

View File

@@ -80,7 +80,7 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
return ( return (
<EmbedAuthenticateView <EmbedAuthenticateView
email={user?.email || recipient.email} email={user?.email || recipient.email}
returnTo={`/embed/sign/${token}`} returnTo={`/embed/direct/${token}`}
/> />
); );
} }

View File

@@ -167,6 +167,7 @@ export const DocumentHistorySheet = ({
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED },

View File

@@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
import type { MessageDescriptor } from '@lingui/core'; import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/macro'; import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock, File } from 'lucide-react'; import { CheckCircle2, Clock, File, TrashIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -47,6 +47,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
labelExtended: msg`Document All`, labelExtended: msg`Document All`,
color: 'text-muted-foreground', color: 'text-muted-foreground',
}, },
BIN: {
label: msg`Bin`,
labelExtended: msg`Document Bin`,
icon: TrashIcon,
color: 'text-red-500 dark:text-red-200',
},
}; };
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & { export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {

View File

@@ -78,14 +78,13 @@ async function middleware(req: NextRequest): Promise<NextResponse> {
if (req.nextUrl.pathname.startsWith('/embed')) { if (req.nextUrl.pathname.startsWith('/embed')) {
const res = NextResponse.next(); const res = NextResponse.next();
const origin = req.headers.get('Origin') ?? '*';
// Allow third parties to iframe the document. // Allow third parties to iframe the document.
res.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.headers.set('Access-Control-Allow-Origin', origin); res.headers.set('Access-Control-Allow-Origin', '*');
res.headers.set('Content-Security-Policy', `frame-ancestors ${origin}`); res.headers.set('Content-Security-Policy', 'frame-ancestors *');
res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
res.headers.set('X-Content-Type-Options', 'nosniff'); res.headers.set('X-Content-Type-Options', 'nosniff');
res.headers.set('X-Frame-Options', 'ALLOW-ALL');
return res; return res;
} }

View File

@@ -1,5 +1,3 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildLogger } from '@documenso/lib/utils/logger';
import * as trpcNext from '@documenso/trpc/server/adapters/next'; import * as trpcNext from '@documenso/trpc/server/adapters/next';
import { createTrpcContext } from '@documenso/trpc/server/context'; import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router'; import { appRouter } from '@documenso/trpc/server/router';
@@ -13,44 +11,7 @@ export const config = {
}, },
}; };
const logger = buildLogger();
export default trpcNext.createNextApiHandler({ export default trpcNext.createNextApiHandler({
router: appRouter, router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }), createContext: async ({ req, res }) => createTrpcContext({ req, res }),
onError(opts) {
const { error, path } = opts;
// Currently trialing changes with template and team router only.
if (!path || (!path.startsWith('template') && !path.startsWith('team'))) {
return;
}
// Always log the error for now.
console.error(error);
const appError = AppError.parseError(error.cause || error);
const isAppError = error.cause instanceof AppError;
// Only log AppErrors that are explicitly set to 500 or the error code
// is in the errorCodesToAlertOn list.
const isLoggableAppError =
isAppError && (appError.statusCode === 500 || errorCodesToAlertOn.includes(appError.code));
// Only log TRPC errors that are in the `errorCodesToAlertOn` list and is
// not an AppError.
const isLoggableTrpcError = !isAppError && errorCodesToAlertOn.includes(error.code);
if (isLoggableAppError || isLoggableTrpcError) {
logger.error(error, {
method: path,
context: {
appError: AppError.toJSON(appError),
},
});
}
},
}); });
const errorCodesToAlertOn = [AppErrorCode.UNKNOWN_ERROR, 'INTERNAL_SERVER_ERROR'];

144
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.8.1", "version": "1.8.1-rc.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.8.1", "version": "1.8.1-rc.1",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@@ -77,7 +77,7 @@
}, },
"apps/marketing": { "apps/marketing": {
"name": "@documenso/marketing", "name": "@documenso/marketing",
"version": "1.8.1", "version": "1.8.1-rc.1",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@documenso/assets": "*", "@documenso/assets": "*",
@@ -438,7 +438,7 @@
}, },
"apps/web": { "apps/web": {
"name": "@documenso/web", "name": "@documenso/web",
"version": "1.8.1", "version": "1.8.1-rc.1",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@documenso/api": "*", "@documenso/api": "*",
@@ -3498,34 +3498,6 @@
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==" "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="
}, },
"node_modules/@honeybadger-io/core": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@honeybadger-io/core/-/core-6.6.0.tgz",
"integrity": "sha512-B5X05huAsDs7NJOYm4bwHf2v0tMuTjBWLfumHH9DCblq8E1XrujlbbNkIdEHlzc01K9oAXuvsaBwVkE7G5+aLQ==",
"dependencies": {
"json-nd": "^1.0.0",
"stacktrace-parser": "^0.1.10"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@honeybadger-io/js": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@honeybadger-io/js/-/js-6.10.1.tgz",
"integrity": "sha512-T5WAhYHWHXFMxjY4NSawSY945i8ISIL5/gsjN3m0xO+oXrBAFaul3wY5p/FGH6r6RfCrjHoHl9Iu7Ed9aO9Ehg==",
"dependencies": {
"@honeybadger-io/core": "^6.6.0",
"@types/aws-lambda": "^8.10.89",
"@types/express": "^4.17.13"
},
"bin": {
"honeybadger-checkins-sync": "scripts/check-ins-sync-bin.js"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz",
@@ -10984,20 +10956,6 @@
"@types/estree": "*" "@types/estree": "*"
} }
}, },
"node_modules/@types/aws-lambda": {
"version": "8.10.146",
"resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.146.tgz",
"integrity": "sha512-3BaDXYTh0e6UCJYL/jwV/3+GRslSc08toAiZSmleYtkAUyV5rtvdPYxrG/88uqvTuT6sb27WE9OS90ZNTIuQ0g=="
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/cacheable-request": { "node_modules/@types/cacheable-request": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
@@ -11010,14 +10968,6 @@
"@types/responselike": "^1.0.0" "@types/responselike": "^1.0.0"
} }
}, },
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -11140,28 +11090,6 @@
"@types/estree": "*" "@types/estree": "*"
} }
}, },
"node_modules/@types/express": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/formidable": { "node_modules/@types/formidable": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-2.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-2.0.6.tgz",
@@ -11204,11 +11132,6 @@
"integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
"dev": true "dev": true
}, },
"node_modules/@types/http-errors": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA=="
},
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -11278,11 +11201,6 @@
"resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.10.tgz",
"integrity": "sha512-Rllzc5KHk0Al5/WANwgSPl1/CwjqCy+AZrGd78zuK+jO9aDM6ffblZ+zIjgPNAaEBmlO0RYDvLNh7wD0zKVgEg==" "integrity": "sha512-Rllzc5KHk0Al5/WANwgSPl1/CwjqCy+AZrGd78zuK+jO9aDM6ffblZ+zIjgPNAaEBmlO0RYDvLNh7wD0zKVgEg=="
}, },
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
},
"node_modules/@types/minimatch": { "node_modules/@types/minimatch": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
@@ -11420,11 +11338,6 @@
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
}, },
"node_modules/@types/qs": {
"version": "6.9.17",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz",
"integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ=="
},
"node_modules/@types/ramda": { "node_modules/@types/ramda": {
"version": "0.29.9", "version": "0.29.9",
"resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.29.9.tgz", "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.29.9.tgz",
@@ -11433,11 +11346,6 @@
"types-ramda": "^0.29.6" "types-ramda": "^0.29.6"
} }
}, },
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.2.18", "version": "18.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.18.tgz",
@@ -11493,25 +11401,6 @@
"integrity": "sha512-T+YwkslhsM+CeuhYUxyAjWm7mJ5am/K10UX40RuA6k6Lc7eGtq8iY2xOzy7Vq0GOqhl/xZl5l2FwURZMTPTUww==", "integrity": "sha512-T+YwkslhsM+CeuhYUxyAjWm7mJ5am/K10UX40RuA6k6Lc7eGtq8iY2xOzy7Vq0GOqhl/xZl5l2FwURZMTPTUww==",
"dev": true "dev": true
}, },
"node_modules/@types/send": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "*"
}
},
"node_modules/@types/swagger-ui-react": { "node_modules/@types/swagger-ui-react": {
"version": "4.18.3", "version": "4.18.3",
"resolved": "https://registry.npmjs.org/@types/swagger-ui-react/-/swagger-ui-react-4.18.3.tgz", "resolved": "https://registry.npmjs.org/@types/swagger-ui-react/-/swagger-ui-react-4.18.3.tgz",
@@ -21122,11 +21011,6 @@
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
}, },
"node_modules/json-nd": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-nd/-/json-nd-1.0.0.tgz",
"integrity": "sha512-8TIp0HZAY0VVrwRQJJPb4+nOTSPoOWZeEKBTLizUfQO4oym5Fc/MKqN8vEbLCxcyxDf2vwNxOQ1q84O49GWPyQ=="
},
"node_modules/json-parse-even-better-errors": { "node_modules/json-parse-even-better-errors": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@@ -31365,25 +31249,6 @@
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true "dev": true
}, },
"node_modules/stacktrace-parser": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz",
"integrity": "sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==",
"dependencies": {
"type-fest": "^0.7.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/stacktrace-parser/node_modules/type-fest": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz",
"integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==",
"engines": {
"node": ">=8"
}
},
"node_modules/stampit": { "node_modules/stampit": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/stampit/-/stampit-4.3.2.tgz", "resolved": "https://registry.npmjs.org/stampit/-/stampit-4.3.2.tgz",
@@ -36619,7 +36484,6 @@
"@documenso/email": "*", "@documenso/email": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@documenso/signing": "*", "@documenso/signing": "*",
"@honeybadger-io/js": "^6.10.1",
"@lingui/core": "^4.11.3", "@lingui/core": "^4.11.3",
"@lingui/macro": "^4.11.3", "@lingui/macro": "^4.11.3",
"@lingui/react": "^4.11.3", "@lingui/react": "^4.11.3",

View File

@@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "1.8.1", "version": "1.8.1-rc.1",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web", "build:web": "turbo run build --filter=@documenso/web",

View File

@@ -244,10 +244,18 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
} }
const dateFormat = body.meta.dateFormat const dateFormat = body.meta.dateFormat
? DATE_FORMATS.find((format) => format.value === body.meta.dateFormat) ? DATE_FORMATS.find((format) => format.label === body.meta.dateFormat)
: DATE_FORMATS.find((format) => format.value === DEFAULT_DOCUMENT_DATE_FORMAT); : DATE_FORMATS.find((format) => format.value === DEFAULT_DOCUMENT_DATE_FORMAT);
const timezone = body.meta.timezone
? TIME_ZONES.find((tz) => tz === body.meta.timezone)
: DEFAULT_DOCUMENT_TIME_ZONE;
if (body.meta.dateFormat && !dateFormat) { const isDateFormatValid = body.meta.dateFormat
? DATE_FORMATS.some((format) => format.label === dateFormat?.label)
: true;
const isTimeZoneValid = body.meta.timezone ? TIME_ZONES.includes(String(timezone)) : true;
if (!isDateFormatValid) {
return { return {
status: 400, status: 400,
body: { body: {
@@ -256,12 +264,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}; };
} }
const timezone = body.meta.timezone
? TIME_ZONES.find((tz) => tz === body.meta.timezone)
: DEFAULT_DOCUMENT_TIME_ZONE;
const isTimeZoneValid = body.meta.timezone ? TIME_ZONES.includes(String(timezone)) : true;
if (!isTimeZoneValid) { if (!isTimeZoneValid) {
return { return {
status: 400, status: 400,
@@ -301,8 +303,6 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
signingOrder: body.meta.signingOrder, signingOrder: body.meta.signingOrder,
language: body.meta.language, language: body.meta.language,
typedSignatureEnabled: body.meta.typedSignatureEnabled, typedSignatureEnabled: body.meta.typedSignatureEnabled,
distributionMethod: body.meta.distributionMethod,
emailSettings: body.meta.emailSettings,
requestMetadata: extractNextApiRequestMetadata(args.req), requestMetadata: extractNextApiRequestMetadata(args.req),
}); });

View File

@@ -10,7 +10,6 @@ import {
ZDocumentActionAuthTypesSchema, ZDocumentActionAuthTypesSchema,
ZRecipientActionAuthTypesSchema, ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { import {
DocumentDataType, DocumentDataType,
@@ -134,12 +133,8 @@ export const ZCreateDocumentMutationSchema = z.object({
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(), language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
typedSignatureEnabled: z.boolean().optional().default(true), typedSignatureEnabled: z.boolean().optional().default(true),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
}) })
.partial() .partial(),
.optional()
.default({}),
authOptions: z authOptions: z
.object({ .object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(), globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
@@ -262,7 +257,6 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
language: z.enum(SUPPORTED_LANGUAGE_CODES), language: z.enum(SUPPORTED_LANGUAGE_CODES),
distributionMethod: z.nativeEnum(DocumentDistributionMethod), distributionMethod: z.nativeEnum(DocumentDistributionMethod),
typedSignatureEnabled: z.boolean(), typedSignatureEnabled: z.boolean(),
emailSettings: ZDocumentEmailSettingsSchema,
}) })
.partial() .partial()
.optional(), .optional(),

View File

@@ -12,10 +12,10 @@ export type GetPricesByIntervalOptions = {
/** /**
* Filter products by their meta 'plan' attribute. * Filter products by their meta 'plan' attribute.
*/ */
plans?: STRIPE_PLAN_TYPE[]; plan?: STRIPE_PLAN_TYPE.COMMUNITY | STRIPE_PLAN_TYPE.REGULAR;
}; };
export const getPricesByInterval = async ({ plans }: GetPricesByIntervalOptions = {}) => { export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => {
let { data: prices } = await stripe.prices.search({ let { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`, query: `active:'true' type:'recurring'`,
expand: ['data.product'], expand: ['data.product'],
@@ -27,8 +27,7 @@ export const getPricesByInterval = async ({ plans }: GetPricesByIntervalOptions
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product; const product = price.product as Stripe.Product;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions const filter = !plan || product.metadata?.plan === plan;
const filter = !plans || plans.includes(product.metadata?.plan as STRIPE_PLAN_TYPE);
// Filter out prices for products that are not active. // Filter out prices for products that are not active.
return product.active && filter; return product.active && filter;

View File

@@ -13,9 +13,7 @@ export const getTeamPrices = async () => {
const priceIds = prices.map((price) => price.id); const priceIds = prices.map((price) => price.id);
if (!monthlyPrice || !yearlyPrice) { if (!monthlyPrice || !yearlyPrice) {
throw new AppError('INVALID_CONFIG', { throw new AppError('INVALID_CONFIG', 'Missing monthly or yearly price');
message: 'Missing monthly or yearly price',
});
} }
return { return {

View File

@@ -43,9 +43,7 @@ export const transferTeamSubscription = async ({
const teamCustomerId = team.customerId; const teamCustomerId = team.customerId;
if (!teamCustomerId) { if (!teamCustomerId) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
message: 'Missing customer ID.',
});
} }
const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([ const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([

View File

@@ -61,7 +61,7 @@ export const TemplateDocumentInvite = ({
<> <>
{includeSenderDetails ? ( {includeSenderDetails ? (
<Trans> <Trans>
{inviterName} on behalf of "{teamName}" has invited you to{' '} {inviterName} on behalf of {teamName} has invited you to{' '}
{_(actionVerb).toLowerCase()} {_(actionVerb).toLowerCase()}
</Trans> </Trans>
) : ( ) : (

View File

@@ -42,7 +42,7 @@ export const DocumentInviteEmailTemplate = ({
if (isTeamInvite) { if (isTeamInvite) {
previewText = includeSenderDetails previewText = includeSenderDetails
? msg`${inviterName} on behalf of "${teamName}" has invited you to ${action} ${documentName}` ? msg`${inviterName} on behalf of ${teamName} has invited you to ${action} ${documentName}`
: msg`${teamName} has invited you to ${action} ${documentName}`; : msg`${teamName} has invited you to ${action} ${documentName}`;
} }
@@ -90,16 +90,14 @@ export const DocumentInviteEmailTemplate = ({
<Container className="mx-auto mt-12 max-w-xl"> <Container className="mx-auto mt-12 max-w-xl">
<Section> <Section>
{!isTeamInvite && ( <Text className="my-4 text-base font-semibold">
<Text className="my-4 text-base font-semibold"> <Trans>
<Trans> {inviterName}{' '}
{inviterName}{' '} <Link className="font-normal text-slate-400" href="mailto:{inviterEmail}">
<Link className="font-normal text-slate-400" href="mailto:{inviterEmail}"> ({inviterEmail})
({inviterEmail}) </Link>
</Link> </Trans>
</Trans> </Text>
</Text>
)}
<Text className="mt-2 text-base text-slate-400"> <Text className="mt-2 text-base text-slate-400">
{customBody ? ( {customBody ? (

View File

@@ -1,4 +1,4 @@
import type { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { z } from 'zod'; import { z } from 'zod';
@@ -8,69 +8,46 @@ import { TRPCClientError } from '@documenso/trpc/client';
* Generic application error codes. * Generic application error codes.
*/ */
export enum AppErrorCode { export enum AppErrorCode {
'ALREADY_EXISTS' = 'ALREADY_EXISTS', 'ALREADY_EXISTS' = 'AlreadyExists',
'EXPIRED_CODE' = 'EXPIRED_CODE', 'EXPIRED_CODE' = 'ExpiredCode',
'INVALID_BODY' = 'INVALID_BODY', 'INVALID_BODY' = 'InvalidBody',
'INVALID_REQUEST' = 'INVALID_REQUEST', 'INVALID_REQUEST' = 'InvalidRequest',
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED', 'LIMIT_EXCEEDED' = 'LimitExceeded',
'NOT_FOUND' = 'NOT_FOUND', 'NOT_FOUND' = 'NotFound',
'NOT_SETUP' = 'NOT_SETUP', 'NOT_SETUP' = 'NotSetup',
'UNAUTHORIZED' = 'UNAUTHORIZED', 'UNAUTHORIZED' = 'Unauthorized',
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR', 'UNKNOWN_ERROR' = 'UnknownError',
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION', 'RETRY_EXCEPTION' = 'RetryException',
'SCHEMA_FAILED' = 'SCHEMA_FAILED', 'SCHEMA_FAILED' = 'SchemaFailed',
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS', 'TOO_MANY_REQUESTS' = 'TooManyRequests',
'PROFILE_URL_TAKEN' = 'PROFILE_URL_TAKEN', 'PROFILE_URL_TAKEN' = 'ProfileUrlTaken',
'PREMIUM_PROFILE_URL' = 'PREMIUM_PROFILE_URL', 'PREMIUM_PROFILE_URL' = 'PremiumProfileUrl',
} }
export const genericErrorCodeToTrpcErrorCodeMap: Record< const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
string, [AppErrorCode.ALREADY_EXISTS]: 'BAD_REQUEST',
{ code: TRPCError['code']; status: number } [AppErrorCode.EXPIRED_CODE]: 'BAD_REQUEST',
> = { [AppErrorCode.INVALID_BODY]: 'BAD_REQUEST',
[AppErrorCode.ALREADY_EXISTS]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.INVALID_REQUEST]: 'BAD_REQUEST',
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.NOT_FOUND]: 'NOT_FOUND',
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.NOT_SETUP]: 'BAD_REQUEST',
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.UNAUTHORIZED]: 'UNAUTHORIZED',
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 }, [AppErrorCode.UNKNOWN_ERROR]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 }, [AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 }, [AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS',
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 }, [AppErrorCode.PROFILE_URL_TAKEN]: 'BAD_REQUEST',
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 }, [AppErrorCode.PREMIUM_PROFILE_URL]: 'BAD_REQUEST',
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.PROFILE_URL_TAKEN]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.PREMIUM_PROFILE_URL]: { code: 'BAD_REQUEST', status: 400 },
}; };
export const ZAppErrorJsonSchema = z.object({ export const ZAppErrorJsonSchema = z.object({
code: z.string(), code: z.string(),
message: z.string().optional(), message: z.string().optional(),
userMessage: z.string().optional(), userMessage: z.string().optional(),
statusCode: z.number().optional(),
}); });
export type TAppErrorJsonSchema = z.infer<typeof ZAppErrorJsonSchema>; export type TAppErrorJsonSchema = z.infer<typeof ZAppErrorJsonSchema>;
type AppErrorOptions = {
/**
* An internal message for logging.
*/
message?: string;
/**
* A message which can be potientially displayed to the user.
*/
userMessage?: string;
/**
* The status code to be associated with the error.
*
* Mainly used for API -> Frontend communication and logging filtering.
*/
statusCode?: number;
};
export class AppError extends Error { export class AppError extends Error {
/** /**
* The error code. * The error code.
@@ -82,11 +59,6 @@ export class AppError extends Error {
*/ */
userMessage?: string; userMessage?: string;
/**
* The status code to be associated with the error.
*/
statusCode?: number;
/** /**
* Create a new AppError. * Create a new AppError.
* *
@@ -94,12 +66,10 @@ export class AppError extends Error {
* @param message An internal error message. * @param message An internal error message.
* @param userMessage A error message which can be displayed to the user. * @param userMessage A error message which can be displayed to the user.
*/ */
public constructor(errorCode: string, options?: AppErrorOptions) { public constructor(errorCode: string, message?: string, userMessage?: string) {
super(options?.message || errorCode); super(message || errorCode);
this.code = errorCode; this.code = errorCode;
this.userMessage = options?.userMessage; this.userMessage = userMessage;
this.statusCode = options?.statusCode;
} }
/** /**
@@ -114,21 +84,16 @@ export class AppError extends Error {
// Handle TRPC errors. // Handle TRPC errors.
if (error instanceof TRPCClientError) { if (error instanceof TRPCClientError) {
const parsedJsonError = AppError.parseFromJSON(error.data?.appError); const parsedJsonError = AppError.parseFromJSONString(error.message);
return parsedJsonError || new AppError('UnknownError', error.message);
const fallbackError = new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: error.message,
});
return parsedJsonError || fallbackError;
} }
// Handle completely unknown errors. // Handle completely unknown errors.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const { code, message, userMessage, statusCode } = error as { const { code, message, userMessage } = error as {
code: unknown; code: unknown;
message: unknown; message: unknown;
statusCode: unknown; status: unknown;
userMessage: unknown; userMessage: unknown;
}; };
@@ -137,15 +102,16 @@ export class AppError extends Error {
const validUserMessage: string | undefined = const validUserMessage: string | undefined =
typeof userMessage === 'string' ? userMessage : undefined; typeof userMessage === 'string' ? userMessage : undefined;
const validStatusCode = typeof statusCode === 'number' ? statusCode : undefined; return new AppError(validCode, validMessage, validUserMessage);
}
const options: AppErrorOptions = { static parseErrorToTRPCError(error: unknown): TRPCError {
message: validMessage, const appError = AppError.parseError(error);
userMessage: validUserMessage,
statusCode: validStatusCode,
};
return new AppError(validCode, options); return new TRPCError({
code: genericErrorCodeToTrpcErrorCodeMap[appError.code] || 'BAD_REQUEST',
message: AppError.toJSONString(appError),
});
} }
/** /**
@@ -154,26 +120,12 @@ export class AppError extends Error {
* @param appError The AppError to convert to JSON. * @param appError The AppError to convert to JSON.
* @returns A JSON object representing the AppError. * @returns A JSON object representing the AppError.
*/ */
static toJSON({ code, message, userMessage, statusCode }: AppError): TAppErrorJsonSchema { static toJSON({ code, message, userMessage }: AppError): TAppErrorJsonSchema {
const data: TAppErrorJsonSchema = { return {
code, code,
message,
userMessage,
}; };
// Explicity only set values if it exists, since TRPC will add meta for undefined
// values which clutters up API responses.
if (message) {
data.message = message;
}
if (userMessage) {
data.userMessage = userMessage;
}
if (statusCode) {
data.statusCode = statusCode;
}
return data;
} }
/** /**
@@ -186,21 +138,15 @@ export class AppError extends Error {
return JSON.stringify(AppError.toJSON(appError)); return JSON.stringify(AppError.toJSON(appError));
} }
static parseFromJSON(value: unknown): AppError | null { static parseFromJSONString(jsonString: string): AppError | null {
try { try {
const parsed = ZAppErrorJsonSchema.safeParse(value); const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
if (!parsed.success) { if (!parsed.success) {
return null; return null;
} }
const { message, userMessage, statusCode } = parsed.data; return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
return new AppError(parsed.data.code, {
message,
userMessage,
statusCode,
});
} catch { } catch {
return null; return null;
} }

View File

@@ -133,7 +133,7 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = {
if (!emailMessage) { if (!emailMessage) {
emailMessage = i18n._( emailMessage = i18n._(
team.teamGlobalSettings?.includeSenderDetails team.teamGlobalSettings?.includeSenderDetails
? msg`${user.name} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".` ? msg`${user.name} on behalf of ${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`, : msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
); );
} }

View File

@@ -21,7 +21,6 @@ import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances'; import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { ZWebhookDocumentSchema } from '../../../types/webhook-payload';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata'; import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { getFile } from '../../../universal/upload/get-file'; import { getFile } from '../../../universal/upload/get-file';
import { putPdfFile } from '../../../universal/upload/put-file'; import { putPdfFile } from '../../../universal/upload/put-file';
@@ -168,10 +167,10 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
const pdfBytes = await pdfDoc.save(); const pdfBytes = await pdfDoc.save();
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) }); const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
const { name } = path.parse(document.title); const { name, ext } = path.parse(document.title);
const documentData = await putPdfFile({ const documentData = await putPdfFile({
name: `${name}_signed.pdf`, name: `${name}_signed${ext}`,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer), arrayBuffer: async () => Promise.resolve(pdfBuffer),
}); });
@@ -250,14 +249,13 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
}, },
include: { include: {
documentData: true, documentData: true,
documentMeta: true,
Recipient: true, Recipient: true,
}, },
}); });
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_COMPLETED, event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(updatedDocument), data: updatedDocument,
userId: updatedDocument.userId, userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined, teamId: updatedDocument.teamId ?? undefined,
}); });

View File

@@ -26,10 +26,6 @@ import { extractNextAuthRequestMetadata } from '../universal/extract-request-met
import { getAuthenticatorOptions } from '../utils/authenticator'; import { getAuthenticatorOptions } from '../utils/authenticator';
import { ErrorCode } from './error-codes'; import { ErrorCode } from './error-codes';
const useSecureCookies =
process.env.NODE_ENV === 'production' && String(process.env.NEXTAUTH_URL).startsWith('https://');
const cookiePrefix = useSecureCookies ? '__Secure-' : '';
export const NEXT_AUTH_OPTIONS: AuthOptions = { export const NEXT_AUTH_OPTIONS: AuthOptions = {
adapter: PrismaAdapter(prisma), adapter: PrismaAdapter(prisma),
secret: process.env.NEXTAUTH_SECRET ?? 'secret', secret: process.env.NEXTAUTH_SECRET ?? 'secret',
@@ -435,53 +431,5 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
return true; return true;
}, },
}, },
cookies: {
sessionToken: {
name: `${cookiePrefix}next-auth.session-token`,
options: {
httpOnly: true,
sameSite: useSecureCookies ? 'none' : 'lax',
path: '/',
secure: useSecureCookies,
},
},
callbackUrl: {
name: `${cookiePrefix}next-auth.callback-url`,
options: {
sameSite: useSecureCookies ? 'none' : 'lax',
path: '/',
secure: useSecureCookies,
},
},
csrfToken: {
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
name: `${cookiePrefix}next-auth.csrf-token`,
options: {
httpOnly: true,
sameSite: useSecureCookies ? 'none' : 'lax',
path: '/',
secure: useSecureCookies,
},
},
pkceCodeVerifier: {
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
options: {
httpOnly: true,
sameSite: useSecureCookies ? 'none' : 'lax',
path: '/',
secure: useSecureCookies,
},
},
state: {
name: `${cookiePrefix}next-auth.state`,
options: {
httpOnly: true,
sameSite: useSecureCookies ? 'none' : 'lax',
path: '/',
secure: useSecureCookies,
},
},
},
// Note: `events` are handled in `apps/web/src/pages/api/auth/[...nextauth].ts` to allow access to the request. // Note: `events` are handled in `apps/web/src/pages/api/auth/[...nextauth].ts` to allow access to the request.
}; };

View File

@@ -25,7 +25,6 @@
"@documenso/email": "*", "@documenso/email": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@documenso/signing": "*", "@documenso/signing": "*",
"@honeybadger-io/js": "^6.10.1",
"@lingui/core": "^4.11.3", "@lingui/core": "^4.11.3",
"@lingui/macro": "^4.11.3", "@lingui/macro": "^4.11.3",
"@lingui/react": "^4.11.3", "@lingui/react": "^4.11.3",
@@ -63,4 +62,4 @@
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@types/pg": "^8.11.4" "@types/pg": "^8.11.4"
} }
} }

View File

@@ -13,6 +13,7 @@ export const getDocumentStats = async () => {
[ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.BIN]: 0,
[ExtendedDocumentStatus.ALL]: 0, [ExtendedDocumentStatus.ALL]: 0,
}; };

View File

@@ -1,148 +0,0 @@
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
export type SigningVolume = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
};
export type GetSigningVolumeOptions = {
search?: string;
page?: number;
perPage?: number;
sortBy?: 'name' | 'createdAt' | 'signingVolume';
sortOrder?: 'asc' | 'desc';
};
export async function getSigningVolume({
search = '',
page = 1,
perPage = 10,
sortBy = 'signingVolume',
sortOrder = 'desc',
}: GetSigningVolumeOptions) {
const whereClause = Prisma.validator<Prisma.SubscriptionWhereInput>()({
status: 'ACTIVE',
OR: [
{
User: {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
},
},
{
team: {
name: { contains: search, mode: 'insensitive' },
},
},
],
});
const orderByClause = getOrderByClause({ sortBy, sortOrder });
const [subscriptions, totalCount] = await Promise.all([
prisma.subscription.findMany({
where: whereClause,
include: {
User: {
include: {
Document: {
where: {
status: 'COMPLETED',
deletedAt: null,
},
},
},
},
team: {
include: {
document: {
where: {
status: 'COMPLETED',
deletedAt: null,
},
},
},
},
},
orderBy: orderByClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
}),
prisma.subscription.count({
where: whereClause,
}),
]);
const leaderboardWithVolume: SigningVolume[] = subscriptions.map((subscription) => {
const name =
subscription.User?.name || subscription.team?.name || subscription.User?.email || 'Unknown';
const userSignedDocs = subscription.User?.Document?.length || 0;
const teamSignedDocs = subscription.team?.document?.length || 0;
return {
id: subscription.id,
name,
signingVolume: userSignedDocs + teamSignedDocs,
createdAt: subscription.createdAt,
planId: subscription.planId,
};
});
return {
leaderboard: leaderboardWithVolume,
totalPages: Math.ceil(totalCount / perPage),
};
}
function getOrderByClause(options: {
sortBy: string;
sortOrder: 'asc' | 'desc';
}): Prisma.SubscriptionOrderByWithRelationInput | Prisma.SubscriptionOrderByWithRelationInput[] {
const { sortBy, sortOrder } = options;
if (sortBy === 'name') {
return [
{
User: {
name: sortOrder,
},
},
{
team: {
name: sortOrder,
},
},
];
}
if (sortBy === 'createdAt') {
return {
createdAt: sortOrder,
};
}
// Default: sort by signing volume
return [
{
User: {
Document: {
_count: sortOrder,
},
},
},
{
team: {
document: {
_count: sortOrder,
},
},
},
];
}

View File

@@ -40,9 +40,7 @@ export const createPasskeyAuthenticationOptions = async ({
}); });
if (!preferredPasskey) { if (!preferredPasskey) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Requested passkey not found');
message: 'Requested passkey not found',
});
} }
} }

View File

@@ -50,9 +50,7 @@ export const createPasskey = async ({
}); });
if (!verificationToken) { if (!verificationToken) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Challenge token not found');
message: 'Challenge token not found',
});
} }
await prisma.verificationToken.deleteMany({ await prisma.verificationToken.deleteMany({
@@ -63,9 +61,7 @@ export const createPasskey = async ({
}); });
if (verificationToken.expires < new Date()) { if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, { throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired');
message: 'Challenge token expired',
});
} }
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions(); const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions();
@@ -78,9 +74,7 @@ export const createPasskey = async ({
}); });
if (!verification.verified || !verification.registrationInfo) { if (!verification.verified || !verification.registrationInfo) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, 'Verification failed');
message: 'Verification failed',
});
} }
const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } = const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } =

View File

@@ -13,7 +13,6 @@ import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client'; import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth'; import type { TRecipientActionAuth } from '../../types/document-auth';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn'; import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendPendingEmail } from './send-pending-email'; import { sendPendingEmail } from './send-pending-email';
@@ -204,19 +203,11 @@ export const completeDocumentWithToken = async ({
}); });
} }
const updatedDocument = await prisma.document.findFirstOrThrow({ const updatedDocument = await getDocument({ token, documentId });
where: {
id: document.id,
},
include: {
documentMeta: true,
Recipient: true,
},
});
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SIGNED, event: WebhookTriggerEvents.DOCUMENT_SIGNED,
data: ZWebhookDocumentSchema.parse(updatedDocument), data: updatedDocument,
userId: updatedDocument.userId, userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined, teamId: updatedDocument.teamId ?? undefined,
}); });

View File

@@ -9,7 +9,6 @@ import { DocumentSource, DocumentVisibility, WebhookTriggerEvents } from '@docum
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client'; import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client'; import { TeamMemberRole } from '@documenso/prisma/client';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = { export type CreateDocumentOptions = {
@@ -48,9 +47,7 @@ export const createDocument = async ({
teamId !== undefined && teamId !== undefined &&
!user.teamMembers.some((teamMember) => teamMember.teamId === teamId) !user.teamMembers.some((teamMember) => teamMember.teamId === teamId)
) { ) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
message: 'Team not found',
});
} }
let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null; let team: (Team & { teamGlobalSettings: TeamGlobalSettings | null }) | null = null;
@@ -136,27 +133,13 @@ export const createDocument = async ({
}), }),
}); });
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentMeta: true,
Recipient: true,
},
});
if (!createdDocument) {
throw new Error('Document not found');
}
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED, event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(createdDocument), data: document,
userId, userId,
teamId, teamId,
}); });
return createdDocument; return document;
}); });
}; };

View File

@@ -158,6 +158,16 @@ const handleDocumentOwnerDelete = async ({
}), }),
}); });
// Soft delete for document recipients since the owner is deleting it
await tx.recipient.updateMany({
where: {
documentId: document.id,
},
data: {
documentDeletedAt: new Date().toISOString(),
},
});
return await tx.document.update({ return await tx.document.update({
where: { where: {
id: document.id, id: document.id,

View File

@@ -64,6 +64,7 @@ export const findDocumentAuditLogs = async ({
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,

View File

@@ -2,15 +2,8 @@ import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client'; import type { Document, DocumentSource, Team, TeamEmail, User } from '@documenso/prisma/client';
import type { import { Prisma, RecipientRole, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
Document,
DocumentSource,
Prisma,
Team,
TeamEmail,
User,
} from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { DocumentVisibility } from '../../types/document-visibility'; import { DocumentVisibility } from '../../types/document-visibility';
@@ -88,14 +81,12 @@ export const findDocuments = async ({
const teamMemberRole = team?.members[0].role ?? null; const teamMemberRole = team?.members[0].role ?? null;
const termFilters = match(term) const termFilters = match(term)
.with(P.string.minLength(1), () => { .with(P.string.minLength(1), () => ({
return { title: {
title: { contains: term,
contains: term, mode: Prisma.QueryMode.insensitive,
mode: 'insensitive', },
}, }))
} as const;
})
.otherwise(() => undefined); .otherwise(() => undefined);
const searchFilter: Prisma.DocumentWhereInput = { const searchFilter: Prisma.DocumentWhereInput = {
@@ -141,6 +132,8 @@ export const findDocuments = async ({
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user); let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user);
console.log('find documets team', team);
if (team) { if (team) {
filters = findTeamDocumentsFilter(status, team, visibilityFilters); filters = findTeamDocumentsFilter(status, team, visibilityFilters);
} }
@@ -293,19 +286,21 @@ export const findDocuments = async ({
} satisfies FindResultSet<typeof data>; } satisfies FindResultSet<typeof data>;
}; };
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => { export const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status) return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
.with(ExtendedDocumentStatus.ALL, () => ({ .with(ExtendedDocumentStatus.ALL, () => ({
OR: [ OR: [
{ {
userId: user.id, userId: user.id,
teamId: null, teamId: null,
deletedAt: null,
}, },
{ {
status: ExtendedDocumentStatus.COMPLETED, status: ExtendedDocumentStatus.COMPLETED,
Recipient: { Recipient: {
some: { some: {
email: user.email, email: user.email,
documentDeletedAt: null,
}, },
}, },
}, },
@@ -314,6 +309,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
Recipient: { Recipient: {
some: { some: {
email: user.email, email: user.email,
documentDeletedAt: null,
}, },
}, },
}, },
@@ -330,6 +326,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
role: { role: {
not: RecipientRole.CC, not: RecipientRole.CC,
}, },
documentDeletedAt: null,
}, },
}, },
})) }))
@@ -344,6 +341,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
userId: user.id, userId: user.id,
teamId: null, teamId: null,
status: ExtendedDocumentStatus.PENDING, status: ExtendedDocumentStatus.PENDING,
deletedAt: null,
}, },
{ {
status: ExtendedDocumentStatus.PENDING, status: ExtendedDocumentStatus.PENDING,
@@ -354,6 +352,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
role: { role: {
not: RecipientRole.CC, not: RecipientRole.CC,
}, },
documentDeletedAt: null,
}, },
}, },
}, },
@@ -365,12 +364,49 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
userId: user.id, userId: user.id,
teamId: null, teamId: null,
status: ExtendedDocumentStatus.COMPLETED, status: ExtendedDocumentStatus.COMPLETED,
deletedAt: null,
}, },
{ {
status: ExtendedDocumentStatus.COMPLETED, status: ExtendedDocumentStatus.COMPLETED,
Recipient: { Recipient: {
some: { some: {
email: user.email, email: user.email,
documentDeletedAt: null,
},
},
},
],
}))
.with(ExtendedDocumentStatus.BIN, () => ({
OR: [
{
userId: user.id,
teamId: null,
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
},
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: {
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
},
},
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: {
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
}, },
}, },
}, },
@@ -408,7 +444,7 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
* @param team The team to find the documents for. * @param team The team to find the documents for.
* @returns A filter which can be applied to the Prisma Document schema. * @returns A filter which can be applied to the Prisma Document schema.
*/ */
const findTeamDocumentsFilter = ( export const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus, status: ExtendedDocumentStatus,
team: Team & { teamEmail: TeamEmail | null }, team: Team & { teamEmail: TeamEmail | null },
visibilityFilters: Prisma.DocumentWhereInput[], visibilityFilters: Prisma.DocumentWhereInput[],
@@ -418,17 +454,16 @@ const findTeamDocumentsFilter = (
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status) return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status)
.with(ExtendedDocumentStatus.ALL, () => { .with(ExtendedDocumentStatus.ALL, () => {
const filter: Prisma.DocumentWhereInput = { const filter: Prisma.DocumentWhereInput = {
// Filter to display all documents that belong to the team.
OR: [ OR: [
{ {
teamId: team.id, teamId: team.id,
deletedAt: null,
OR: visibilityFilters, OR: visibilityFilters,
}, },
], ],
}; };
if (teamEmail && filter.OR) { if (teamEmail && filter.OR) {
// Filter to display all documents received by the team email that are not draft.
filter.OR.push({ filter.OR.push({
status: { status: {
not: ExtendedDocumentStatus.DRAFT, not: ExtendedDocumentStatus.DRAFT,
@@ -438,14 +473,15 @@ const findTeamDocumentsFilter = (
email: teamEmail, email: teamEmail,
}, },
}, },
deletedAt: null,
OR: visibilityFilters, OR: visibilityFilters,
}); });
// Filter to display all documents that have been sent by the team email.
filter.OR.push({ filter.OR.push({
User: { User: {
email: teamEmail, email: teamEmail,
}, },
deletedAt: null,
OR: visibilityFilters, OR: visibilityFilters,
}); });
} }
@@ -453,7 +489,6 @@ const findTeamDocumentsFilter = (
return filter; return filter;
}) })
.with(ExtendedDocumentStatus.INBOX, () => { .with(ExtendedDocumentStatus.INBOX, () => {
// Return a filter that will return nothing.
if (!teamEmail) { if (!teamEmail) {
return null; return null;
} }
@@ -471,6 +506,7 @@ const findTeamDocumentsFilter = (
}, },
}, },
}, },
deletedAt: null,
OR: visibilityFilters, OR: visibilityFilters,
}; };
}) })
@@ -480,6 +516,7 @@ const findTeamDocumentsFilter = (
{ {
teamId: team.id, teamId: team.id,
status: ExtendedDocumentStatus.DRAFT, status: ExtendedDocumentStatus.DRAFT,
deletedAt: null,
OR: visibilityFilters, OR: visibilityFilters,
}, },
], ],
@@ -491,6 +528,7 @@ const findTeamDocumentsFilter = (
User: { User: {
email: teamEmail, email: teamEmail,
}, },
deletedAt: null,
OR: visibilityFilters, OR: visibilityFilters,
}); });
} }
@@ -503,6 +541,7 @@ const findTeamDocumentsFilter = (
{ {
teamId: team.id, teamId: team.id,
status: ExtendedDocumentStatus.PENDING, status: ExtendedDocumentStatus.PENDING,
deletedAt: null,
OR: visibilityFilters, OR: visibilityFilters,
}, },
], ],
@@ -531,6 +570,7 @@ const findTeamDocumentsFilter = (
OR: visibilityFilters, OR: visibilityFilters,
}, },
], ],
deletedAt: null,
}); });
} }
@@ -539,6 +579,7 @@ const findTeamDocumentsFilter = (
.with(ExtendedDocumentStatus.COMPLETED, () => { .with(ExtendedDocumentStatus.COMPLETED, () => {
const filter: Prisma.DocumentWhereInput = { const filter: Prisma.DocumentWhereInput = {
status: ExtendedDocumentStatus.COMPLETED, status: ExtendedDocumentStatus.COMPLETED,
deletedAt: null,
OR: [ OR: [
{ {
teamId: team.id, teamId: team.id,
@@ -568,5 +609,42 @@ const findTeamDocumentsFilter = (
return filter; return filter;
}) })
.with(ExtendedDocumentStatus.BIN, () => {
const filters: Prisma.DocumentWhereInput[] = [
{
teamId: team.id,
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
},
];
if (teamEmail) {
filters.push(
{
User: {
email: teamEmail,
},
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
},
{
Recipient: {
some: {
email: teamEmail,
documentDeletedAt: {
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
},
},
},
);
}
return {
OR: filters,
};
})
.exhaustive(); .exhaustive();
}; };

View File

@@ -4,7 +4,6 @@ import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client'; import type { Prisma } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client'; import { TeamMemberRole } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentVisibility } from '../../types/document-visibility'; import { DocumentVisibility } from '../../types/document-visibility';
import { getTeamById } from '../team/get-team'; import { getTeamById } from '../team/get-team';
@@ -21,7 +20,7 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
teamId, teamId,
}); });
const document = await prisma.document.findFirst({ return await prisma.document.findFirstOrThrow({
where: documentWhereInput, where: documentWhereInput,
include: { include: {
documentData: true, documentData: true,
@@ -46,14 +45,6 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
}, },
}, },
}); });
if (!document) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document could not be found',
});
}
return document;
}; };
export type GetDocumentWhereInputOptions = { export type GetDocumentWhereInputOptions = {

View File

@@ -81,17 +81,6 @@ export const getDocumentAndSenderByToken = async ({
token, token,
}, },
}, },
team: {
select: {
name: true,
teamEmail: true,
teamGlobalSettings: {
select: {
includeSenderDetails: true,
},
},
},
},
}, },
}); });
@@ -118,9 +107,7 @@ export const getDocumentAndSenderByToken = async ({
} }
if (!documentAccessValid) { if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
message: 'Invalid access values',
});
} }
return { return {
@@ -180,9 +167,7 @@ export const getDocumentAndRecipientByToken = async ({
} }
if (!documentAccessValid) { if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid access values');
message: 'Invalid access values',
});
} }
return { return {

View File

@@ -0,0 +1,118 @@
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import {
type PeriodSelectorValue,
findDocumentsFilter,
findTeamDocumentsFilter,
} from '@documenso/lib/server-only/document/find-documents';
import { prisma } from '@documenso/prisma';
import type { Prisma, Team, TeamEmail, User } from '@documenso/prisma/client';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export type GetStatsInput = {
user: User;
team?: Team & { teamEmail: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } };
period?: PeriodSelectorValue;
search?: string;
};
export const getStats = async ({ user, period, search, ...options }: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
createdAt = {
gte: startOfPeriod.toJSDate(),
};
}
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
[ExtendedDocumentStatus.BIN]: 0,
};
const searchFilter: Prisma.DocumentWhereInput = search
? {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
],
}
: {};
const visibilityFilters = [
match(options.team?.currentTeamMember?.role)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })),
];
const statusCounts = await Promise.all(
Object.values(ExtendedDocumentStatus).map(async (status) => {
if (status === ExtendedDocumentStatus.ALL) {
return;
}
const filter = options.team
? findTeamDocumentsFilter(status, options.team, visibilityFilters)
: findDocumentsFilter(status, user);
if (filter === null) {
return { status, count: 0 };
}
const whereClause = {
...filter,
...(createdAt && { createdAt }),
...searchFilter,
};
const count = await prisma.document.count({
where: whereClause,
});
return { status, count };
}),
);
statusCounts.forEach((result) => {
if (result) {
stats[result.status] = result.count;
if (
result.status !== ExtendedDocumentStatus.BIN &&
[
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.INBOX,
].includes(result.status)
) {
stats[ExtendedDocumentStatus.ALL] += result.count;
}
}
});
return stats;
};

View File

@@ -1,12 +1,16 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
// eslint-disable-next-line import/no-extraneous-dependencies
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import type { Prisma, User } from '@documenso/prisma/client'; import type { Prisma, User } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client'; import {
import { DocumentVisibility } from '@documenso/prisma/client'; DocumentVisibility,
RecipientRole,
SigningStatus,
TeamMemberRole,
} from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -30,7 +34,7 @@ export const getStats = async ({ user, period, search, ...options }: GetStatsInp
}; };
} }
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team const [ownerCounts, notSignedCounts, hasSignedCounts, deletedCounts] = await (options.team
? getTeamCounts({ ? getTeamCounts({
...options.team, ...options.team,
createdAt, createdAt,
@@ -46,6 +50,7 @@ export const getStats = async ({ user, period, search, ...options }: GetStatsInp
[ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0, [ExtendedDocumentStatus.ALL]: 0,
[ExtendedDocumentStatus.BIN]: 0,
}; };
ownerCounts.forEach((stat) => { ownerCounts.forEach((stat) => {
@@ -66,6 +71,10 @@ export const getStats = async ({ user, period, search, ...options }: GetStatsInp
} }
}); });
deletedCounts.forEach((stat) => {
stats[ExtendedDocumentStatus.BIN] += stat._count._all;
});
Object.keys(stats).forEach((key) => { Object.keys(stats).forEach((key) => {
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) { if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
stats[ExtendedDocumentStatus.ALL] += stats[key]; stats[ExtendedDocumentStatus.ALL] += stats[key];
@@ -98,25 +107,45 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
_all: true, _all: true,
}, },
where: { where: {
userId: user.id, OR: [
{
userId: user.id,
teamId: null,
deletedAt: null,
},
{
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: user.email,
documentDeletedAt: null,
},
},
},
],
createdAt, createdAt,
teamId: null,
deletedAt: null,
AND: [searchFilter], AND: [searchFilter],
}, },
}), }),
// Not signed counts. // Not signed counts (Inbox).
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
_all: true, _all: true,
}, },
where: { where: {
status: ExtendedDocumentStatus.PENDING, status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: { Recipient: {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
documentDeletedAt: null, documentDeletedAt: null,
}, },
}, },
@@ -131,30 +160,81 @@ const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
_all: true, _all: true,
}, },
where: { where: {
createdAt,
User: {
email: {
not: user.email,
},
},
OR: [ OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.PENDING,
deletedAt: null,
},
{ {
status: ExtendedDocumentStatus.PENDING, status: ExtendedDocumentStatus.PENDING,
Recipient: { Recipient: {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
role: {
not: RecipientRole.CC,
},
documentDeletedAt: null, documentDeletedAt: null,
}, },
}, },
}, },
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.COMPLETED,
deletedAt: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
documentDeletedAt: null,
},
},
},
],
createdAt,
AND: [searchFilter],
},
}),
// Deleted counts.
prisma.document.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: {
OR: [
{
userId: user.id,
teamId: null,
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
},
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: {
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
},
},
},
{ {
status: ExtendedDocumentStatus.COMPLETED, status: ExtendedDocumentStatus.COMPLETED,
Recipient: { Recipient: {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, documentDeletedAt: {
documentDeletedAt: null, gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
}, },
}, },
}, },
@@ -177,9 +257,7 @@ type GetTeamCountsOption = {
}; };
const getTeamCounts = async (options: GetTeamCountsOption) => { const getTeamCounts = async (options: GetTeamCountsOption) => {
const { createdAt, teamId, teamEmail } = options; const { createdAt, teamId, teamEmail, senderIds = [], currentTeamMemberRole, search } = options;
const senderIds = options.senderIds ?? [];
const userIdWhereClause: Prisma.DocumentWhereInput['userId'] = const userIdWhereClause: Prisma.DocumentWhereInput['userId'] =
senderIds.length > 0 senderIds.length > 0
@@ -188,148 +266,226 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
} }
: undefined; : undefined;
const searchFilter: Prisma.DocumentWhereInput = { const searchFilter: Prisma.DocumentWhereInput = search
OR: [ ? {
{ title: { contains: options.search, mode: 'insensitive' } },
{ Recipient: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
{ Recipient: { some: { email: { contains: options.search, mode: 'insensitive' } } } },
],
};
let ownerCountsWhereInput: Prisma.DocumentWhereInput = {
userId: userIdWhereClause,
createdAt,
teamId,
deletedAt: null,
};
let notSignedCountsGroupByArgs = null;
let hasSignedCountsGroupByArgs = null;
const visibilityFiltersWhereInput: Prisma.DocumentWhereInput = {
AND: [
{ deletedAt: null },
{
OR: [ OR: [
match(options.currentTeamMemberRole) { title: { contains: search, mode: 'insensitive' } },
.with(TeamMemberRole.ADMIN, () => ({ { Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
visibility: { { Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({
visibility: {
equals: DocumentVisibility.EVERYONE,
},
})),
{
OR: [
{ userId: options.userId },
{ Recipient: { some: { email: options.currentUserEmail } } },
],
},
], ],
}, }
], : {};
};
ownerCountsWhereInput = { const visibilityFilters = [
...ownerCountsWhereInput, match(currentTeamMemberRole)
...visibilityFiltersWhereInput, .with(TeamMemberRole.ADMIN, () => ({
...searchFilter, visibility: {
}; in: [
DocumentVisibility.EVERYONE,
if (teamEmail) { DocumentVisibility.MANAGER_AND_ABOVE,
ownerCountsWhereInput = { DocumentVisibility.ADMIN,
userId: userIdWhereClause, ],
createdAt,
OR: [
{
teamId,
}, },
{ }))
User: { .with(TeamMemberRole.MANAGER, () => ({
email: teamEmail, visibility: {
}, in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
}, },
], }))
deletedAt: null, .otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })),
}; ];
notSignedCountsGroupByArgs = {
by: ['status'],
_count: {
_all: true,
},
where: {
userId: userIdWhereClause,
createdAt,
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
},
},
deletedAt: null,
},
} satisfies Prisma.DocumentGroupByArgs;
hasSignedCountsGroupByArgs = {
by: ['status'],
_count: {
_all: true,
},
where: {
userId: userIdWhereClause,
createdAt,
OR: [
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
deletedAt: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
},
},
deletedAt: null,
},
],
},
} satisfies Prisma.DocumentGroupByArgs;
}
return Promise.all([ return Promise.all([
// Owner counts (ALL)
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: { _all: true },
_all: true, where: {
OR: [
{
teamId,
deletedAt: null,
OR: visibilityFilters,
},
...(teamEmail
? [
{
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: teamEmail,
documentDeletedAt: null,
},
},
deletedAt: null,
OR: visibilityFilters,
},
{
User: {
email: teamEmail,
},
deletedAt: null,
OR: visibilityFilters,
},
]
: []),
],
userId: userIdWhereClause,
createdAt,
...searchFilter,
},
}),
// Not signed counts (INBOX)
prisma.document.groupBy({
by: ['status'],
_count: { _all: true },
where: teamEmail
? {
userId: userIdWhereClause,
createdAt,
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
},
},
deletedAt: null,
OR: visibilityFilters,
...searchFilter,
}
: {
userId: userIdWhereClause,
createdAt,
AND: [
{
OR: [{ id: -1 }], // Empty set if no team email
},
searchFilter,
],
},
}),
// Has signed counts (PENDING + COMPLETED)
prisma.document.groupBy({
by: ['status'],
_count: { _all: true },
where: {
userId: userIdWhereClause,
createdAt,
OR: [
{
teamId,
status: ExtendedDocumentStatus.PENDING,
deletedAt: null,
OR: visibilityFilters,
},
{
teamId,
status: ExtendedDocumentStatus.COMPLETED,
deletedAt: null,
OR: visibilityFilters,
},
...(teamEmail
? [
{
status: ExtendedDocumentStatus.PENDING,
OR: [
{
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
role: {
not: RecipientRole.CC,
},
documentDeletedAt: null,
},
},
OR: visibilityFilters,
},
{
User: {
email: teamEmail,
},
OR: visibilityFilters,
},
],
deletedAt: null,
},
{
status: ExtendedDocumentStatus.COMPLETED,
OR: [
{
Recipient: {
some: {
email: teamEmail,
documentDeletedAt: null,
},
},
OR: visibilityFilters,
},
{
User: {
email: teamEmail,
},
OR: visibilityFilters,
},
],
deletedAt: null,
},
]
: []),
],
...searchFilter,
},
}),
// Deleted counts (BIN)
prisma.document.groupBy({
by: ['status'],
_count: { _all: true },
where: {
OR: [
{
teamId,
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
},
...(teamEmail
? [
{
User: {
email: teamEmail,
},
deletedAt: {
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
},
{
Recipient: {
some: {
email: teamEmail,
documentDeletedAt: {
gte: DateTime.now().minus({ days: 30 }).startOf('day').toJSDate(),
},
},
},
},
]
: []),
],
...searchFilter,
}, },
where: ownerCountsWhereInput,
}), }),
notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [],
hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [],
]); ]);
}; };

View File

@@ -106,9 +106,7 @@ export const isRecipientAuthorized = async ({
// Should not be possible. // Should not be possible.
if (!user) { if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
message: 'User not found',
});
} }
return await verifyTwoFactorAuthenticationToken({ return await verifyTwoFactorAuthenticationToken({
@@ -166,9 +164,7 @@ const verifyPasskey = async ({
}); });
if (!passkey) { if (!passkey) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
message: 'Passkey not found',
});
} }
const verificationToken = await prisma.verificationToken const verificationToken = await prisma.verificationToken
@@ -181,15 +177,11 @@ const verifyPasskey = async ({
.catch(() => null); .catch(() => null);
if (!verificationToken) { if (!verificationToken) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found');
message: 'Token not found',
});
} }
if (verificationToken.expires < new Date()) { if (verificationToken.expires < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, { throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired');
message: 'Token expired',
});
} }
const { rpId, origin } = getAuthenticatorOptions(); const { rpId, origin } = getAuthenticatorOptions();
@@ -207,9 +199,7 @@ const verifyPasskey = async ({
}).catch(() => null); // May want to log this for insights. }).catch(() => null); // May want to log this for insights.
if (verification?.verified !== true) { if (verification?.verified !== true) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized');
message: 'User is not authorized',
});
} }
await prisma.passkey.update({ await prisma.passkey.update({

View File

@@ -3,13 +3,10 @@ import { TRPCError } from '@trpc/server';
import { jobs } from '@documenso/lib/jobs/client'; import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type RejectDocumentWithTokenOptions = { export type RejectDocumentWithTokenOptions = {
token: string; token: string;
@@ -34,8 +31,6 @@ export async function rejectDocumentWithToken({
Document: { Document: {
include: { include: {
User: true, User: true,
Recipient: true,
documentMeta: true,
}, },
}, },
}, },
@@ -50,6 +45,8 @@ export async function rejectDocumentWithToken({
}); });
} }
// Add the audit log entry before updating the recipient
// Update the recipient status to rejected // Update the recipient status to rejected
const [updatedRecipient] = await prisma.$transaction([ const [updatedRecipient] = await prisma.$transaction([
prisma.recipient.update({ prisma.recipient.update({
@@ -91,28 +88,5 @@ export async function rejectDocumentWithToken({
}, },
}); });
// Get the updated document with all recipients
const updatedDocument = await prisma.document.findFirst({
where: {
id: document.id,
},
include: {
Recipient: true,
documentMeta: true,
},
});
if (!updatedDocument) {
throw new Error('Document not found after update');
}
// Trigger webhook for document rejection
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REJECTED,
data: ZWebhookDocumentSchema.parse(updatedDocument),
userId: document.userId,
teamId: document.teamId ?? undefined,
});
return updatedRecipient; return updatedRecipient;
} }

View File

@@ -134,7 +134,7 @@ export const resendDocument = async ({
emailMessage = emailMessage =
customEmail?.message || customEmail?.message ||
i18n._( i18n._(
msg`${user.name} on behalf of "${document.team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`, msg`${user.name} on behalf of ${document.team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
); );
} }

View File

@@ -0,0 +1,149 @@
'use server';
import { prisma } from '@documenso/prisma';
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type RestoreDocumentOptions = {
id: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const restoreDocument = async ({
id,
userId,
teamId,
requestMetadata,
}: RestoreDocumentOptions) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new Error('User not found');
}
const document = await prisma.document.findUnique({
where: {
id,
},
include: {
Recipient: true,
documentMeta: true,
team: {
select: {
members: true,
},
},
},
});
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
throw new Error('Document not found');
}
const isUserOwner = document.userId === userId;
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email);
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
throw new Error('Not allowed');
}
// Handle restoring the actual document if user has permission.
if (isUserOwner || isUserTeamMember) {
await handleDocumentOwnerRestore({
document,
user,
requestMetadata,
});
}
// Continue to show the document to the user if they are a recipient.
if (userRecipient?.documentDeletedAt !== null) {
await prisma.recipient
.update({
where: {
id: userRecipient?.id,
},
data: {
documentDeletedAt: null,
},
})
.catch(() => {
// Do nothing.
});
}
// Return partial document for API v1 response.
return {
id: document.id,
userId: document.userId,
teamId: document.teamId,
title: document.title,
status: document.status,
documentDataId: document.documentDataId,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
completedAt: document.completedAt,
};
};
type HandleDocumentOwnerRestoreOptions = {
document: Document & {
Recipient: Recipient[];
documentMeta: DocumentMeta | null;
};
user: User;
requestMetadata?: RequestMetadata;
};
const handleDocumentOwnerRestore = async ({
document,
user,
requestMetadata,
}: HandleDocumentOwnerRestoreOptions) => {
if (!document.deletedAt) {
return;
}
// Restore soft-deleted documents.
return await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED,
user,
requestMetadata,
data: {
type: 'RESTORE',
},
}),
});
await tx.recipient.updateMany({
where: {
documentId: document.id,
},
data: {
documentDeletedAt: null,
},
});
return await tx.document.update({
where: {
id: document.id,
},
data: {
deletedAt: null,
},
});
});
};

View File

@@ -10,7 +10,6 @@ import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/
import { WebhookTriggerEvents } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing'; import { signPdf } from '@documenso/signing';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { putPdfFile } from '../../universal/upload/put-file'; import { putPdfFile } from '../../universal/upload/put-file';
@@ -137,10 +136,10 @@ export const sealDocument = async ({
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) }); const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
const { name } = path.parse(document.title); const { name, ext } = path.parse(document.title);
const { data: newData } = await putPdfFile({ const { data: newData } = await putPdfFile({
name: `${name}_signed.pdf`, name: `${name}_signed${ext}`,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer), arrayBuffer: async () => Promise.resolve(pdfBuffer),
}); });
@@ -200,14 +199,13 @@ export const sealDocument = async ({
}, },
include: { include: {
documentData: true, documentData: true,
documentMeta: true,
Recipient: true, Recipient: true,
}, },
}); });
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_COMPLETED, event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(updatedDocument), data: updatedDocument,
userId: document.userId, userId: document.userId,
teamId: document.teamId ?? undefined, teamId: document.teamId ?? undefined,
}); });

View File

@@ -14,7 +14,6 @@ import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client'; import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -115,14 +114,8 @@ export const sendDocument = async ({
formValues: document.formValues as Record<string, string | number | boolean>, formValues: document.formValues as Record<string, string | number | boolean>,
}); });
let fileName = document.title;
if (!document.title.endsWith('.pdf')) {
fileName = `${document.title}.pdf`;
}
const newDocumentData = await putPdfFile({ const newDocumentData = await putPdfFile({
name: fileName, name: document.title,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled), arrayBuffer: async () => Promise.resolve(prefilled),
}); });
@@ -237,7 +230,6 @@ export const sendDocument = async ({
status: DocumentStatus.PENDING, status: DocumentStatus.PENDING,
}, },
include: { include: {
documentMeta: true,
Recipient: true, Recipient: true,
}, },
}); });
@@ -245,7 +237,7 @@ export const sendDocument = async ({
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SENT, event: WebhookTriggerEvents.DOCUMENT_SENT,
data: ZWebhookDocumentSchema.parse(updatedDocument), data: updatedDocument,
userId, userId,
teamId, teamId,
}); });

View File

@@ -37,9 +37,7 @@ export const updateDocumentSettings = async ({
requestMetadata, requestMetadata,
}: UpdateDocumentSettingsOptions) => { }: UpdateDocumentSettingsOptions) => {
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) { if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
throw new AppError(AppErrorCode.INVALID_BODY, { throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
message: 'Missing data to update',
});
} }
const user = await prisma.user.findFirstOrThrow({ const user = await prisma.user.findFirstOrThrow({
@@ -98,9 +96,10 @@ export const updateDocumentSettings = async ({
!allowedVisibilities.includes(document.visibility) || !allowedVisibilities.includes(document.visibility) ||
(data.visibility && !allowedVisibilities.includes(data.visibility)) (data.visibility && !allowedVisibilities.includes(data.visibility))
) { ) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(
message: 'You do not have permission to update the document visibility', AppErrorCode.UNAUTHORIZED,
}); 'You do not have permission to update the document visibility',
);
} }
}) })
.with(TeamMemberRole.MEMBER, () => { .with(TeamMemberRole.MEMBER, () => {
@@ -108,15 +107,17 @@ export const updateDocumentSettings = async ({
document.visibility !== DocumentVisibility.EVERYONE || document.visibility !== DocumentVisibility.EVERYONE ||
(data.visibility && data.visibility !== DocumentVisibility.EVERYONE) (data.visibility && data.visibility !== DocumentVisibility.EVERYONE)
) { ) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(
message: 'You do not have permission to update the document visibility', AppErrorCode.UNAUTHORIZED,
}); 'You do not have permission to update the document visibility',
);
} }
}) })
.otherwise(() => { .otherwise(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(
message: 'You do not have permission to update the document', AppErrorCode.UNAUTHORIZED,
}); 'You do not have permission to update the document',
);
}); });
} }
@@ -141,9 +142,10 @@ export const updateDocumentSettings = async ({
}); });
if (!isDocumentEnterprise) { if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(
message: 'You do not have permission to set the action auth', AppErrorCode.UNAUTHORIZED,
}); 'You do not have permission to set the action auth',
);
} }
} }
@@ -159,9 +161,10 @@ export const updateDocumentSettings = async ({
const auditLogs: CreateDocumentAuditLogDataResponse[] = []; const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) { if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
throw new AppError(AppErrorCode.INVALID_BODY, { throw new AppError(
message: 'You cannot update the title if the document has been sent', AppErrorCode.INVALID_BODY,
}); 'You cannot update the title if the document has been sent',
);
} }
if (!isTitleSame) { if (!isTitleSame) {

View File

@@ -45,9 +45,7 @@ export const validateFieldAuth = async ({
}); });
if (!isValid) { if (!isValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
message: 'Invalid authentication values',
});
} }
return derivedRecipientActionAuth; return derivedRecipientActionAuth;

View File

@@ -6,8 +6,8 @@ import { ReadStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client';
import type { TDocumentAccessAuthTypes } from '../../types/document-auth'; import type { TDocumentAccessAuthTypes } from '../../types/document-auth';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getDocumentAndRecipientByToken } from './get-document-by-token';
export type ViewedDocumentOptions = { export type ViewedDocumentOptions = {
token: string; token: string;
@@ -63,23 +63,11 @@ export const viewedDocument = async ({
}); });
}); });
const document = await prisma.document.findFirst({ const document = await getDocumentAndRecipientByToken({ token, requireAccessAuth: false });
where: {
id: documentId,
},
include: {
documentMeta: true,
Recipient: true,
},
});
if (!document) {
throw new Error('Document not found');
}
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_OPENED, event: WebhookTriggerEvents.DOCUMENT_OPENED,
data: ZWebhookDocumentSchema.parse(document), data: document,
userId: document.userId, userId: document.userId,
teamId: document.teamId ?? undefined, teamId: document.teamId ?? undefined,
}); });

View File

@@ -5,7 +5,11 @@ import { getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags'; import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL, NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app'; import {
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
NEXT_PUBLIC_MARKETING_URL,
NEXT_PUBLIC_WEBAPP_URL,
} from '../../constants/app';
import { extractDistinctUserId, mapJwtToFlagProperties } from './get'; import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
/** /**

View File

@@ -7,7 +7,11 @@ import { getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags'; import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL, NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app'; import {
NEXT_PRIVATE_INTERNAL_WEBAPP_URL,
NEXT_PUBLIC_MARKETING_URL,
NEXT_PUBLIC_WEBAPP_URL,
} from '../../constants/app';
/** /**
* Evaluate a single feature flag based on the current user if possible. * Evaluate a single feature flag based on the current user if possible.
@@ -67,7 +71,7 @@ export default async function handleFeatureFlagGet(req: Request) {
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) { if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
res.headers.set('Access-Control-Allow-Origin', origin); res.headers.set('Access-Control-Allow-Origin', origin);
} }
if (origin.startsWith(NEXT_PRIVATE_INTERNAL_WEBAPP_URL ?? 'http://localhost:3000')) { if (origin.startsWith(NEXT_PRIVATE_INTERNAL_WEBAPP_URL ?? 'http://localhost:3000')) {
res.headers.set('Access-Control-Allow-Origin', origin); res.headers.set('Access-Control-Allow-Origin', origin);
} }

View File

@@ -104,9 +104,7 @@ export const setFieldsForDocument = async ({
// Each field MUST have a recipient associated with it. // Each field MUST have a recipient associated with it.
if (!recipient) { if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, `Recipient not found for field ${field.id}`);
message: `Recipient not found for field ${field.id}`,
});
} }
// Check whether the existing field can be modified. // Check whether the existing field can be modified.
@@ -115,10 +113,10 @@ export const setFieldsForDocument = async ({
hasFieldBeenChanged(existing, field) && hasFieldBeenChanged(existing, field) &&
!canRecipientFieldsBeModified(recipient, existingFields) !canRecipientFieldsBeModified(recipient, existingFields)
) { ) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(
message: AppErrorCode.INVALID_REQUEST,
'Cannot modify a field where the recipient has already interacted with the document', 'Cannot modify a field where the recipient has already interacted with the document',
}); );
} }
return { return {

View File

@@ -115,9 +115,7 @@ export const getPublicProfileByUrl = async ({
// Log as critical error. // Log as critical error.
if (user?.profile && team?.profile) { if (user?.profile && team?.profile) {
console.error('Profile URL is ambiguous', { profileUrl, userId: user.id, teamId: team.id }); console.error('Profile URL is ambiguous', { profileUrl, userId: user.id, teamId: team.id });
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, 'Profile URL is ambiguous');
message: 'Profile URL is ambiguous',
});
} }
if (user?.profile?.enabled) { if (user?.profile?.enabled) {
@@ -179,7 +177,5 @@ export const getPublicProfileByUrl = async ({
}; };
} }
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Profile not found');
message: 'Profile not found',
});
}; };

View File

@@ -18,9 +18,10 @@ export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) =>
}); });
if (teamMember?.role !== TeamMemberRole.ADMIN) { if (teamMember?.role !== TeamMemberRole.ADMIN) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(
message: 'You do not have the required permissions to view this page.', AppErrorCode.UNAUTHORIZED,
}); 'You do not have the required permissions to view this page.',
);
} }
return await prisma.apiToken.findMany({ return await prisma.apiToken.findMany({

View File

@@ -105,9 +105,10 @@ export const setRecipientsForDocument = async ({
}); });
if (!isDocumentEnterprise) { if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(
message: 'You do not have permission to set the action auth', AppErrorCode.UNAUTHORIZED,
}); 'You do not have permission to set the action auth',
);
} }
} }
@@ -141,9 +142,10 @@ export const setRecipientsForDocument = async ({
hasRecipientBeenChanged(existing, recipient) && hasRecipientBeenChanged(existing, recipient) &&
!canRecipientBeModified(existing, document.Field) !canRecipientBeModified(existing, document.Field)
) { ) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(
message: 'Cannot modify a recipient who has already interacted with the document', AppErrorCode.INVALID_REQUEST,
}); 'Cannot modify a recipient who has already interacted with the document',
);
} }
return { return {

View File

@@ -72,9 +72,10 @@ export const setRecipientsForTemplate = async ({
}); });
if (!isDocumentEnterprise) { if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(
message: 'You do not have permission to set the action auth', AppErrorCode.UNAUTHORIZED,
}); 'You do not have permission to set the action auth',
);
} }
} }
@@ -118,15 +119,14 @@ export const setRecipientsForTemplate = async ({
); );
if (updatedDirectRecipient?.role === RecipientRole.CC) { if (updatedDirectRecipient?.role === RecipientRole.CC) {
throw new AppError(AppErrorCode.INVALID_BODY, { throw new AppError(AppErrorCode.INVALID_BODY, 'Cannot set direct recipient as CC');
message: 'Cannot set direct recipient as CC',
});
} }
if (deletedDirectRecipient) { if (deletedDirectRecipient) {
throw new AppError(AppErrorCode.INVALID_BODY, { throw new AppError(
message: 'Cannot delete direct recipient while direct template exists', AppErrorCode.INVALID_BODY,
}); 'Cannot delete direct recipient while direct template exists',
);
} }
} }

View File

@@ -96,9 +96,10 @@ export const updateRecipient = async ({
}); });
if (!isDocumentEnterprise) { if (!isDocumentEnterprise) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(
message: 'You do not have permission to set the action auth', AppErrorCode.UNAUTHORIZED,
}); 'You do not have permission to set the action auth',
);
} }
} }

View File

@@ -47,8 +47,6 @@ export const createTeamPendingCheckoutSession = async ({
console.error(e); console.error(e);
// Absorb all the errors incase Stripe throws something sensitive. // Absorb all the errors incase Stripe throws something sensitive.
throw new AppError(AppErrorCode.UNKNOWN_ERROR, { throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Something went wrong.');
message: 'Something went wrong.',
});
} }
}; };

View File

@@ -55,9 +55,10 @@ export const createTeamEmailVerification = async ({
}); });
if (team.teamEmail || team.emailVerification) { if (team.teamEmail || team.emailVerification) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(
message: 'Team already has an email or existing email verification.', AppErrorCode.INVALID_REQUEST,
}); 'Team already has an email or existing email verification.',
);
} }
const existingTeamEmail = await tx.teamEmail.findFirst({ const existingTeamEmail = await tx.teamEmail.findFirst({
@@ -67,9 +68,7 @@ export const createTeamEmailVerification = async ({
}); });
if (existingTeamEmail) { if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, { throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
message: 'Email already taken by another team.',
});
} }
const { token, expiresAt } = createTokenVerification({ hours: 1 }); const { token, expiresAt } = createTokenVerification({ hours: 1 });
@@ -98,9 +97,7 @@ export const createTeamEmailVerification = async ({
const target = z.array(z.string()).safeParse(err.meta?.target); const target = z.array(z.string()).safeParse(err.meta?.target);
if (err.code === 'P2002' && target.success && target.data.includes('email')) { if (err.code === 'P2002' && target.success && target.data.includes('email')) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, { throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
message: 'Email already taken by another team.',
});
} }
throw err; throw err;

View File

@@ -69,9 +69,7 @@ export const createTeamMemberInvites = async ({
const currentTeamMember = team.members.find((member) => member.user.id === userId); const currentTeamMember = team.members.find((member) => member.user.id === userId);
if (!currentTeamMember) { if (!currentTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, 'User not part of team.');
message: 'User not part of team.',
});
} }
const usersToInvite = invitations.filter((invitation) => { const usersToInvite = invitations.filter((invitation) => {
@@ -93,9 +91,10 @@ export const createTeamMemberInvites = async ({
); );
if (unauthorizedRoleAccess) { if (unauthorizedRoleAccess) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(
message: 'User does not have permission to set high level roles', AppErrorCode.UNAUTHORIZED,
}); 'User does not have permission to set high level roles',
);
} }
const teamMemberInvites = usersToInvite.map(({ email, role }) => ({ const teamMemberInvites = usersToInvite.map(({ email, role }) => ({
@@ -128,10 +127,11 @@ export const createTeamMemberInvites = async ({
if (sendEmailResultErrorList.length > 0) { if (sendEmailResultErrorList.length > 0) {
console.error(JSON.stringify(sendEmailResultErrorList)); console.error(JSON.stringify(sendEmailResultErrorList));
throw new AppError('EmailDeliveryFailed', { throw new AppError(
message: 'Failed to send invite emails to one or more users.', 'EmailDeliveryFailed',
userMessage: `Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`, 'Failed to send invite emails to one or more users.',
}); `Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`,
);
} }
}; };

View File

@@ -87,9 +87,7 @@ export const createTeam = async ({
}); });
if (existingUserProfileWithUrl) { if (existingUserProfileWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, { throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
message: 'URL already taken.',
});
} }
await tx.team.create({ await tx.team.create({
@@ -133,21 +131,15 @@ export const createTeam = async ({
}); });
if (existingUserProfileWithUrl) { if (existingUserProfileWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, { throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
message: 'URL already taken.',
});
} }
if (existingTeamWithUrl) { if (existingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, { throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
message: 'Team URL already exists.',
});
} }
if (!customerId) { if (!customerId) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, { throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Missing customer ID for pending teams.');
message: 'Missing customer ID for pending teams.',
});
} }
return await tx.teamPending.create({ return await tx.teamPending.create({
@@ -174,9 +166,7 @@ export const createTeam = async ({
const target = z.array(z.string()).safeParse(err.meta?.target); const target = z.array(z.string()).safeParse(err.meta?.target);
if (err.code === 'P2002' && target.success && target.data.includes('url')) { if (err.code === 'P2002' && target.success && target.data.includes('url')) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, { throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
message: 'Team URL already exists.',
});
} }
throw err; throw err;

View File

@@ -60,13 +60,11 @@ export const deleteTeamMembers = async ({
); );
if (!currentTeamMember) { if (!currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
message: 'Team member record does not exist',
});
} }
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) { if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Cannot remove the team owner' }); throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
} }
const isMemberToRemoveHigherRole = teamMembersToRemove.some( const isMemberToRemoveHigherRole = teamMembersToRemove.some(
@@ -74,9 +72,7 @@ export const deleteTeamMembers = async ({
); );
if (isMemberToRemoveHigherRole) { if (isMemberToRemoveHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
message: 'Cannot remove a member with a higher role',
});
} }
// Remove the team members. // Remove the team members.

View File

@@ -24,9 +24,7 @@ export const findTeamInvoices = async ({ userId, teamId }: FindTeamInvoicesOptio
}); });
if (!team.customerId) { if (!team.customerId) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Team has no customer ID.');
message: 'Team has no customer ID.',
});
} }
const results = await getInvoices({ customerId: team.customerId }); const results = await getInvoices({ customerId: team.customerId });

View File

@@ -33,9 +33,7 @@ export const getTeamPublicProfile = async ({
}); });
if (!team) { if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
message: 'Team not found',
});
} }
// Create and return the public profile. // Create and return the public profile.
@@ -49,9 +47,7 @@ export const getTeamPublicProfile = async ({
}); });
if (!profile) { if (!profile) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Failed to create public profile');
message: 'Failed to create public profile',
});
} }
return { return {

View File

@@ -38,17 +38,16 @@ export const resendTeamEmailVerification = async ({
}); });
if (!team) { if (!team) {
throw new AppError('TeamNotFound', { throw new AppError('TeamNotFound', 'User is not a member of the team.');
message: 'User is not a member of the team.',
});
} }
const { emailVerification } = team; const { emailVerification } = team;
if (!emailVerification) { if (!emailVerification) {
throw new AppError('VerificationNotFound', { throw new AppError(
message: 'No team email verification exists for this team.', 'VerificationNotFound',
}); 'No team email verification exists for this team.',
);
} }
const { token, expiresAt } = createTokenVerification({ hours: 1 }); const { token, expiresAt } = createTokenVerification({ hours: 1 });

View File

@@ -55,7 +55,7 @@ export const resendTeamMemberInvitation = async ({
}); });
if (!team) { if (!team) {
throw new AppError('TeamNotFound', { message: 'User is not a valid member of the team.' }); throw new AppError('TeamNotFound', 'User is not a valid member of the team.');
} }
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({ const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
@@ -66,7 +66,7 @@ export const resendTeamMemberInvitation = async ({
}); });
if (!teamMemberInvite) { if (!teamMemberInvite) {
throw new AppError('InviteNotFound', { message: 'No invite exists for this user.' }); throw new AppError('InviteNotFound', 'No invite exists for this user.');
} }
await sendTeamMemberInviteEmail({ await sendTeamMemberInviteEmail({

View File

@@ -48,11 +48,11 @@ export const updateTeamMember = async ({
const teamMemberToUpdate = team.members.find((member) => member.id === teamMemberId); const teamMemberToUpdate = team.members.find((member) => member.id === teamMemberId);
if (!teamMemberToUpdate || !currentTeamMember) { if (!teamMemberToUpdate || !currentTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Team member does not exist' }); throw new AppError(AppErrorCode.NOT_FOUND, 'Team member does not exist');
} }
if (teamMemberToUpdate.userId === team.ownerUserId) { if (teamMemberToUpdate.userId === team.ownerUserId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Cannot update the owner' }); throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update the owner');
} }
const isMemberToUpdateHigherRole = !isTeamRoleWithinUserHierarchy( const isMemberToUpdateHigherRole = !isTeamRoleWithinUserHierarchy(
@@ -61,9 +61,7 @@ export const updateTeamMember = async ({
); );
if (isMemberToUpdateHigherRole) { if (isMemberToUpdateHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update a member with a higher role');
message: 'Cannot update a member with a higher role',
});
} }
const isNewMemberRoleHigherThanCurrentRole = !isTeamRoleWithinUserHierarchy( const isNewMemberRoleHigherThanCurrentRole = !isTeamRoleWithinUserHierarchy(
@@ -72,9 +70,10 @@ export const updateTeamMember = async ({
); );
if (isNewMemberRoleHigherThanCurrentRole) { if (isNewMemberRoleHigherThanCurrentRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(
message: 'Cannot give a member a role higher than the user initating the update', AppErrorCode.UNAUTHORIZED,
}); 'Cannot give a member a role higher than the user initating the update',
);
} }
return await tx.teamMember.update({ return await tx.teamMember.update({

View File

@@ -24,9 +24,7 @@ export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) =>
}); });
if (foundPendingTeamWithUrl) { if (foundPendingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, { throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
message: 'Team URL already exists.',
});
} }
const team = await tx.team.update({ const team = await tx.team.update({
@@ -59,9 +57,7 @@ export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) =>
const target = z.array(z.string()).safeParse(err.meta?.target); const target = z.array(z.string()).safeParse(err.meta?.target);
if (err.code === 'P2002' && target.success && target.data.includes('url')) { if (err.code === 'P2002' && target.success && target.data.includes('url')) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, { throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
message: 'Team URL already exists.',
});
} }
throw err; throw err;

View File

@@ -31,7 +31,6 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth'; import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta'; import { ZFieldMetaSchema } from '../../types/field-meta';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs'; import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
@@ -102,7 +101,7 @@ export const createDocumentFromDirectTemplate = async ({
}); });
if (!template?.directLink?.enabled) { if (!template?.directLink?.enabled) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' }); throw new AppError(AppErrorCode.INVALID_REQUEST, 'Invalid or missing template');
} }
const { Recipient: recipients, directLink, User: templateOwner } = template; const { Recipient: recipients, directLink, User: templateOwner } = template;
@@ -112,19 +111,15 @@ export const createDocumentFromDirectTemplate = async ({
); );
if (!directTemplateRecipient || directTemplateRecipient.role === RecipientRole.CC) { if (!directTemplateRecipient || directTemplateRecipient.role === RecipientRole.CC) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, 'Invalid or missing direct recipient');
message: 'Invalid or missing direct recipient',
});
} }
if (template.updatedAt.getTime() !== templateUpdatedAt.getTime()) { if (template.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' }); throw new AppError(AppErrorCode.INVALID_REQUEST, 'Template no longer matches');
} }
if (user && user.email !== directRecipientEmail) { if (user && user.email !== directRecipientEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, 'Email must match if you are logged in');
message: 'Email must match if you are logged in',
});
} }
const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } = const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } =
@@ -141,7 +136,7 @@ export const createDocumentFromDirectTemplate = async ({
.exhaustive(); .exhaustive();
if (!isAccessAuthValid) { if (!isAccessAuthValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'You must be logged in' }); throw new AppError(AppErrorCode.UNAUTHORIZED, 'You must be logged in');
} }
const directTemplateRecipientAuthOptions = ZRecipientAuthOptionsSchema.parse( const directTemplateRecipientAuthOptions = ZRecipientAuthOptionsSchema.parse(
@@ -168,9 +163,7 @@ export const createDocumentFromDirectTemplate = async ({
); );
if (!signedFieldValue) { if (!signedFieldValue) {
throw new AppError(AppErrorCode.INVALID_BODY, { throw new AppError(AppErrorCode.INVALID_BODY, 'Invalid, missing or changed fields');
message: 'Invalid, missing or changed fields',
});
} }
if (templateField.type === FieldType.NAME && directRecipientName === undefined) { if (templateField.type === FieldType.NAME && directRecipientName === undefined) {
@@ -592,7 +585,7 @@ export const createDocumentFromDirectTemplate = async ({
requestMetadata, requestMetadata,
}); });
const createdDocument = await prisma.document.findFirstOrThrow({ const updatedDocument = await prisma.document.findFirstOrThrow({
where: { where: {
id: documentId, id: documentId,
}, },
@@ -604,9 +597,9 @@ export const createDocumentFromDirectTemplate = async ({
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_SIGNED, event: WebhookTriggerEvents.DOCUMENT_SIGNED,
data: ZWebhookDocumentSchema.parse(createdDocument), data: updatedDocument,
userId: template.userId, userId: updatedDocument.userId,
teamId: template.teamId ?? undefined, teamId: updatedDocument.teamId ?? undefined,
}); });
} catch (err) { } catch (err) {
console.error('[CREATE_DOCUMENT_FROM_DIRECT_TEMPLATE]:', err); console.error('[CREATE_DOCUMENT_FROM_DIRECT_TEMPLATE]:', err);

View File

@@ -16,9 +16,7 @@ import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import type { TDocumentEmailSettings } from '../../types/document-email';
import { ZFieldMetaSchema } from '../../types/field-meta'; import { ZFieldMetaSchema } from '../../types/field-meta';
import { ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { import {
@@ -67,7 +65,6 @@ export type CreateDocumentFromTemplateOptions = {
language?: SupportedLanguageCodes; language?: SupportedLanguageCodes;
distributionMethod?: DocumentDistributionMethod; distributionMethod?: DocumentDistributionMethod;
typedSignatureEnabled?: boolean; typedSignatureEnabled?: boolean;
emailSettings?: TDocumentEmailSettings;
}; };
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
}; };
@@ -123,9 +120,7 @@ export const createDocumentFromTemplate = async ({
}); });
if (!template) { if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
message: 'Template not found',
});
} }
// Check that all the passed in recipient IDs can be associated with a template recipient. // Check that all the passed in recipient IDs can be associated with a template recipient.
@@ -135,9 +130,10 @@ export const createDocumentFromTemplate = async ({
); );
if (!foundRecipient) { if (!foundRecipient) {
throw new AppError(AppErrorCode.INVALID_BODY, { throw new AppError(
message: `Recipient with ID ${recipient.id} not found in the template.`, AppErrorCode.INVALID_BODY,
}); `Recipient with ID ${recipient.id} not found in the template.`,
);
} }
}); });
@@ -192,9 +188,7 @@ export const createDocumentFromTemplate = async ({
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl, redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
distributionMethod: distributionMethod:
override?.distributionMethod || template.templateMeta?.distributionMethod, override?.distributionMethod || template.templateMeta?.distributionMethod,
// last `undefined` is due to JsonValue's emailSettings: template.templateMeta?.emailSettings || undefined,
emailSettings:
override?.emailSettings || template.templateMeta?.emailSettings || undefined,
signingOrder: signingOrder:
override?.signingOrder || override?.signingOrder ||
template.templateMeta?.signingOrder || template.templateMeta?.signingOrder ||
@@ -292,23 +286,9 @@ export const createDocumentFromTemplate = async ({
}), }),
}); });
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentMeta: true,
Recipient: true,
},
});
if (!createdDocument) {
throw new Error('Document not found');
}
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED, event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(createdDocument), data: document,
userId, userId,
teamId, teamId,
}); });

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