mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
47 Commits
feat/team-
...
v1.9.1-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 123a709836 | |||
| ae6cc24317 | |||
| a41ac632d0 | |||
| 1789eff564 | |||
| 235d846d2b | |||
| ca3d65ad10 | |||
| 617e3a46e0 | |||
| 255c33cdab | |||
| 1560218d4a | |||
| 5c7768c253 | |||
| 7bb93e4233 | |||
| 42e39f7ef1 | |||
| 838e399c73 | |||
| b6a891acc8 | |||
| 84b4d58856 | |||
| c51c32fdc6 | |||
| 59c1e55233 | |||
| 2fbaf56c06 | |||
| 70320cd24b | |||
| 00b46561c2 | |||
| 11bc93a9a4 | |||
| 11528090a5 | |||
| 3c4863f285 | |||
| 2ff330f9d4 | |||
| ce1c93b2a6 | |||
| 82337e4e3a | |||
| 7d9a3f9776 | |||
| cbad065dac | |||
| 25a3861c91 | |||
| b9ae277041 | |||
| 7fad826d06 | |||
| eb8ba2036a | |||
| 339759166c | |||
| 637e06f9c0 | |||
| 332e0657e0 | |||
| 4017b250fb | |||
| 41373a7c6f | |||
| 7cc85ca6bc | |||
| bc19fa0cbd | |||
| a60f58e20b | |||
| aca902b5ff | |||
| 2f866c41b4 | |||
| 7e4faef95f | |||
| bcef84787d | |||
| 70a3ac0525 | |||
| c6fb101a99 | |||
| 2984af769c |
@ -1,4 +1,4 @@
|
||||
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="documen.so/sign-everywhere">The Platform Plan</a>!
|
||||
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="https://documen.so/sign-everywhere">The Platform Plan</a>!
|
||||
|
||||
<a href="https://www.producthunt.com/posts/documenso-platform-plan?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-documenso-platform-plan" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=670576&theme=light" alt="Documenso Platform Plan - Whitelabeled signing flows in your product | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
|
||||
@ -14,4 +14,4 @@
|
||||
"public-api": "Public API",
|
||||
"embedding": "Embedding",
|
||||
"webhooks": "Webhooks"
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,5 +6,6 @@
|
||||
"solid": "Solid Integration",
|
||||
"preact": "Preact Integration",
|
||||
"angular": "Angular Integration",
|
||||
"css-variables": "CSS Variables"
|
||||
"css-variables": "CSS Variables",
|
||||
"web-components": "Web Components"
|
||||
}
|
||||
|
||||
@ -111,6 +111,83 @@ The colors will be automatically converted to the appropriate format internally.
|
||||
|
||||
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
|
||||
|
||||
## CSS Class Targets
|
||||
|
||||
In addition to CSS variables, specific components in the embedded experience can be targeted using CSS classes for more granular styling:
|
||||
|
||||
### Component Classes
|
||||
|
||||
| Class Name | Description |
|
||||
| --------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `.embed--Root` | Main container for the embedded signing experience |
|
||||
| `.embed--DocumentContainer` | Container for the document and signing widget |
|
||||
| `.embed--DocumentViewer` | Container for the document viewer |
|
||||
| `.embed--DocumentWidget` | The signing widget container |
|
||||
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget, handles positioning |
|
||||
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
|
||||
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
|
||||
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
|
||||
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
|
||||
| `.embed--WaitingForTurn` | Container for the waiting screen when it's not the user's turn to sign |
|
||||
| `.embed--DocumentCompleted` | Container for the completion screen after signing |
|
||||
| `.field--FieldRootContainer` | Base container for document fields (signatures, text, checkboxes, etc.) |
|
||||
|
||||
Field components also expose several data attributes that can be used for styling different states:
|
||||
|
||||
| Data Attribute | Values | Description |
|
||||
| ------------------- | ---------------------------------------------- | ------------------------------------ |
|
||||
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
|
||||
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
|
||||
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
|
||||
|
||||
### Field Styling Example
|
||||
|
||||
```css
|
||||
/* Style all field containers */
|
||||
.field--FieldRootContainer {
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
/* Style specific field types */
|
||||
.field--FieldRootContainer[data-field-type='SIGNATURE'] {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Style inserted fields */
|
||||
.field--FieldRootContainer[data-inserted='true'] {
|
||||
background-color: var(--primary);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* Style fields being validated */
|
||||
.field--FieldRootContainer[data-validate='true'] {
|
||||
border-color: orange;
|
||||
}
|
||||
```
|
||||
|
||||
### Example Usage
|
||||
|
||||
```css
|
||||
/* Custom styles for the document widget */
|
||||
.embed--DocumentWidget {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Custom styles for the waiting screen */
|
||||
.embed--WaitingForTurn {
|
||||
background-color: #f9fafb;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for the document container */
|
||||
@media (min-width: 768px) {
|
||||
.embed--DocumentContainer {
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [React Integration](/developers/embedding/react)
|
||||
|
||||
@ -73,14 +73,15 @@ These customization options are available for both Direct Templates and Signing
|
||||
|
||||
We support embedding across a range of popular JavaScript frameworks, including:
|
||||
|
||||
| Framework | Package |
|
||||
| --------- | ---------------------------------------------------------------------------------- |
|
||||
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
|
||||
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
|
||||
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
|
||||
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
|
||||
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
|
||||
| Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) |
|
||||
| Framework | Package |
|
||||
| --------- | ---------------------------------------------------------------------------------- |
|
||||
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
|
||||
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
|
||||
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
|
||||
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
|
||||
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
|
||||
| Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) |
|
||||
| Web Components | [@documenso/embed-webcomponent](https://www.npmjs.com/package/@documenso/embed-webcomponent) |
|
||||
|
||||
Additionally, we provide **web components** for more generalized use. However, please note that web components are still in their early stages and haven't been extensively tested.
|
||||
|
||||
@ -166,6 +167,7 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
|
||||
- [Svelte](/developers/embedding/svelte)
|
||||
- [Solid](/developers/embedding/solid)
|
||||
- [Angular](/developers/embedding/angular)
|
||||
- [Web Components](/developers/embedding/web-components)
|
||||
|
||||
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.
|
||||
|
||||
@ -177,4 +179,5 @@ If you're using **web components**, the integration process is slightly differen
|
||||
- [Solid Integration](/developers/embedding/solid)
|
||||
- [Preact Integration](/developers/embedding/preact)
|
||||
- [Angular Integration](/developers/embedding/angular)
|
||||
- [Web Components](/developers/embedding/web-components)
|
||||
- [CSS Variables](/developers/embedding/css-variables)
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
---
|
||||
title: Web Components Integration
|
||||
description: Learn how to use our embedding SDK via Web Components on a framework-less web application.
|
||||
---
|
||||
|
||||
# Web Components Integration
|
||||
|
||||
Our Web Components SDK provides a simple way to embed a signing experience within your framework-less web application. It supports both direct link templates and signing tokens.
|
||||
|
||||
## Installation
|
||||
|
||||
To install the SDK, run the following command:
|
||||
|
||||
```bash
|
||||
npm install @documenso/embed-webcomponent
|
||||
```
|
||||
|
||||
Then in your html file, add the following to add the script, replacing the path with the proper path to the web component script.
|
||||
|
||||
```html
|
||||
<script src="YOUR_PATH_HERE"></script>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
|
||||
|
||||
### Direct Link Template
|
||||
|
||||
If you have a direct link template, you can simply provide the token for the template to the `documenso-embed-direct-template` tag.
|
||||
|
||||
```html
|
||||
<documenso-embed-direct-template
|
||||
token="YOUR_TOKEN_HERE"
|
||||
</documenso-embed-direct-template>
|
||||
```
|
||||
|
||||
#### Attributes
|
||||
|
||||
| Attribute | Type | Description |
|
||||
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| token | string | The token for the document you want to embed |
|
||||
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||
| onFieldSigned | function (optional) | A callback function that will be called when a field is signed |
|
||||
| onFieldUnsigned | function (optional) | A callback function that will be called when a field is unsigned |
|
||||
|
||||
### Signing Token
|
||||
|
||||
If you have a signing token, you can provide it to the `documenso-embed-sign-document` tag.
|
||||
|
||||
```html
|
||||
<documenso-embed-sign-document
|
||||
token="YOUR_TOKEN_HERE"
|
||||
</documenso-embed-sign-document>
|
||||
```
|
||||
|
||||
#### Attributes
|
||||
|
||||
| Attribute | Type | Description |
|
||||
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| token | string | The token for the document you want to embed |
|
||||
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
||||
| name | string (optional) | The name the signer that will be used by default for signing |
|
||||
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
||||
| 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 |
|
||||
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
||||
|
||||
### Creating via JavaScript
|
||||
|
||||
You can also create the tag element using javascript, for dynamic generation of either modes. For example, this would add the sign document embed to the DOM.
|
||||
|
||||
```javascript
|
||||
document.getElementById('my-wrapper-here').innerHTML = '';
|
||||
|
||||
const tag = document.createElement('documenso-embed-sign-document');
|
||||
tag.setAttribute('token', data.token);
|
||||
tag.style.width = '100%';
|
||||
tag.style.height = '100%';
|
||||
|
||||
document.getElementById('my-wrapper-here').appendChild(tag);
|
||||
```
|
||||
@ -3,6 +3,8 @@ title: Public API
|
||||
description: Learn how to interact with your documents programmatically using the Documenso public API.
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Public API
|
||||
|
||||
Documenso provides a public REST API enabling you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as:
|
||||
@ -13,10 +15,35 @@ Documenso provides a public REST API enabling you to interact with your document
|
||||
|
||||
The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API.
|
||||
|
||||
## Swagger Documentation
|
||||
## API V1 - Stable
|
||||
|
||||
The [Swagger documentation](https://app.documenso.com/api/v1/openapi) also provides information about the API endpoints, request parameters, response formats, and authentication methods.
|
||||
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
||||
|
||||
## API V2 - Beta
|
||||
|
||||
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
|
||||
|
||||
Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
||||
|
||||
Our new API V2 supports the following typed SDKs:
|
||||
|
||||
- [TypeScript](https://github.com/documenso/sdk-typescript)
|
||||
- [Python](https://github.com/documenso/sdk-python)
|
||||
- [Go](https://github.com/documenso/sdk-go)
|
||||
|
||||
<Callout type="info">
|
||||
For the staging API, please use the following base URL:
|
||||
`https://stg-app.documenso.dev/api/v2-beta/`
|
||||
</Callout>
|
||||
|
||||
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
||||
|
||||
📖 [Documentation](https://documen.so/api-v2-docs)
|
||||
|
||||
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
||||
|
||||
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
||||
|
||||
## Availability
|
||||
|
||||
The API is available to individual users and teams.
|
||||
The API is available to individual users, teams and higher plans. [Fair Use](https://documen.so/fair) applies.
|
||||
|
||||
@ -21,6 +21,7 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
|
||||
- `document.signed`
|
||||
- `document.completed`
|
||||
- `document.rejected`
|
||||
- `document.cancelled`
|
||||
|
||||
## Create a webhook subscription
|
||||
|
||||
@ -37,7 +38,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:
|
||||
|
||||
- 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`, `document.rejected`, `document.cancelled`.
|
||||
- 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.
|
||||
|
||||

|
||||
@ -528,6 +529,96 @@ Example payload for the `document.rejected` event:
|
||||
}
|
||||
```
|
||||
|
||||
Example payload for the `document.rejected` event:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_CANCELLED",
|
||||
"payload": {
|
||||
"id": 7,
|
||||
"externalId": null,
|
||||
"userId": 3,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "documenso.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "cm6exvn93006hi02ru90a265a",
|
||||
"createdAt": "2025-01-27T11:02:14.393Z",
|
||||
"updatedAt": "2025-01-27T11:03:16.387Z",
|
||||
"completedAt": null,
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "cm6exvn96006ji02rqvzjvwoy",
|
||||
"subject": "",
|
||||
"message": "",
|
||||
"timezone": "Etc/UTC",
|
||||
"password": null,
|
||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||
"redirectUrl": "",
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": {
|
||||
"documentDeleted": true,
|
||||
"documentPending": true,
|
||||
"recipientSigned": true,
|
||||
"recipientRemoved": true,
|
||||
"documentCompleted": true,
|
||||
"ownerDocumentCompleted": true,
|
||||
"recipientSigningRequest": true
|
||||
}
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": 7,
|
||||
"documentId": 7,
|
||||
"templateId": null,
|
||||
"email": "mybirihix@mailinator.com",
|
||||
"name": "Zorita Baird",
|
||||
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": null,
|
||||
"authOptions": { "accessAuth": null, "actionAuth": null },
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
],
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 7,
|
||||
"documentId": 7,
|
||||
"templateId": null,
|
||||
"email": "signer@documenso.com",
|
||||
"name": "Signer",
|
||||
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": null,
|
||||
"authOptions": { "accessAuth": null, "actionAuth": null },
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2025-01-27T11:03:27.730Z",
|
||||
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
|
||||
}
|
||||
```
|
||||
|
||||
## Availability
|
||||
|
||||
Webhooks are available to individual users and teams.
|
||||
|
||||
@ -85,12 +85,13 @@ You can also set the recipient's role, which determines their actions and permis
|
||||
|
||||
Documenso has 4 roles for recipients with different permissions and actions.
|
||||
|
||||
| Role | Function | Action required | Signature |
|
||||
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
||||
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
||||
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
||||
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
||||
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
||||
| Role | Function | Action required | Signature |
|
||||
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
||||
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
||||
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
||||
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
||||
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
|
||||
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
||||
|
||||
### Fields
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "1.9.0-rc.11",
|
||||
"version": "1.9.1-rc.8",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@ -4,7 +4,7 @@ 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 { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, 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';
|
||||
@ -54,7 +54,15 @@ export const LeaderboardTable = ({
|
||||
onClick={() => handleColumnSort('name')}
|
||||
>
|
||||
{_(msg`Name`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
{sortBy === 'name' ? (
|
||||
sortOrder === 'asc' ? (
|
||||
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'name',
|
||||
@ -80,7 +88,15 @@ export const LeaderboardTable = ({
|
||||
onClick={() => handleColumnSort('signingVolume')}
|
||||
>
|
||||
{_(msg`Signing Volume`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
{sortBy === 'signingVolume' ? (
|
||||
sortOrder === 'asc' ? (
|
||||
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'signingVolume',
|
||||
@ -94,7 +110,15 @@ export const LeaderboardTable = ({
|
||||
onClick={() => handleColumnSort('createdAt')}
|
||||
>
|
||||
{_(msg`Created`)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
{sortBy === 'createdAt' ? (
|
||||
sortOrder === 'asc' ? (
|
||||
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@ -102,7 +126,7 @@ export const LeaderboardTable = ({
|
||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
||||
},
|
||||
] satisfies DataTableColumnDef<SigningVolume>[];
|
||||
}, [sortOrder]);
|
||||
}, [sortOrder, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
@ -133,6 +157,9 @@ export const LeaderboardTable = ({
|
||||
const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
search: debouncedSearchString,
|
||||
page,
|
||||
perPage,
|
||||
sortBy: column,
|
||||
sortOrder: sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc',
|
||||
});
|
||||
|
||||
@ -12,13 +12,14 @@ import {
|
||||
MailOpenIcon,
|
||||
PenIcon,
|
||||
PlusIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@ -120,6 +121,12 @@ export const DocumentPageViewRecipients = ({
|
||||
<Trans>Viewed</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<>
|
||||
<UserIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Assisted</Trans>
|
||||
</>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@ -162,7 +162,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* We don't want to allow teams moving documents across at the moment. */}
|
||||
{!team && (
|
||||
{!team && !row.teamId && (
|
||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Team</Trans>
|
||||
|
||||
@ -40,7 +40,7 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
|
||||
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||
|
||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
|
||||
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
|
||||
onSuccess: () => {
|
||||
|
||||
@ -14,6 +14,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog';
|
||||
|
||||
import { DataTableActionDropdown } from '../data-table-action-dropdown';
|
||||
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
|
||||
@ -111,6 +112,8 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
|
||||
<TemplateDirectLinkDialogWrapper template={template} />
|
||||
|
||||
<TemplateBulkSendDialog templateId={template.id} recipients={template.recipients} />
|
||||
|
||||
<Button className="w-full" asChild>
|
||||
<Link href={`${templateRootPath}/${template.id}/edit`}>
|
||||
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
||||
@ -5,7 +5,7 @@ import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2, Upload } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
||||
@ -17,6 +17,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog';
|
||||
|
||||
import { DeleteTemplateDialog } from './delete-template-dialog';
|
||||
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||
import { MoveTemplateDialog } from './move-template-dialog';
|
||||
@ -79,13 +81,24 @@ export const DataTableActionDropdown = ({
|
||||
<Trans>Direct link</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!teamId && (
|
||||
{!teamId && !row.teamId && (
|
||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Team</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<TemplateBulkSendDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
trigger={
|
||||
<div className="hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Bulk Send via CSV</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
|
||||
@ -42,7 +42,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
|
||||
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||
|
||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
|
||||
@ -77,7 +77,11 @@ export const TemplateDirectLinkDialog = ({
|
||||
);
|
||||
|
||||
const validDirectTemplateRecipients = useMemo(
|
||||
() => template.recipients.filter((recipient) => recipient.role !== RecipientRole.CC),
|
||||
() =>
|
||||
template.recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
||||
),
|
||||
[template.recipients],
|
||||
);
|
||||
|
||||
|
||||
@ -47,6 +47,7 @@ import { NameField } from '~/app/(signing)/sign/[token]/name-field';
|
||||
import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
|
||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { RadioField } from '~/app/(signing)/sign/[token]/radio-field';
|
||||
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
|
||||
import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
|
||||
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
|
||||
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
|
||||
@ -169,7 +170,7 @@ export const SignDirectTemplateForm = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<RecipientProvider recipient={directRecipient}>
|
||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
@ -186,16 +187,15 @@ export const SignDirectTemplateForm = ({
|
||||
<SignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
<InitialsField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -204,7 +204,6 @@ export const SignDirectTemplateForm = ({
|
||||
<NameField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -213,7 +212,6 @@ export const SignDirectTemplateForm = ({
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
onSignField={onSignField}
|
||||
@ -224,7 +222,6 @@ export const SignDirectTemplateForm = ({
|
||||
<EmailField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -241,7 +238,6 @@ export const SignDirectTemplateForm = ({
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -259,7 +255,6 @@ export const SignDirectTemplateForm = ({
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -277,7 +272,6 @@ export const SignDirectTemplateForm = ({
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -295,7 +289,6 @@ export const SignDirectTemplateForm = ({
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -313,7 +306,6 @@ export const SignDirectTemplateForm = ({
|
||||
...field,
|
||||
fieldMeta: parsedFieldMeta,
|
||||
}}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -351,6 +343,7 @@ export const SignDirectTemplateForm = ({
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
allowTypedSignature={template.templateMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -383,6 +376,6 @@ export const SignDirectTemplateForm = ({
|
||||
/>
|
||||
</div>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</>
|
||||
</RecipientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||
|
||||
type ConfirmationDialogProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
hasUninsertedFields: boolean;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
export function AssistantConfirmationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
hasUninsertedFields,
|
||||
isSubmitting,
|
||||
}: ConfirmationDialogProps) {
|
||||
const onOpenChange = () => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Complete Document</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Are you sure you want to complete the document? This action cannot be undone. Please
|
||||
ensure that you have completed prefilling all relevant fields before proceeding.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SigningDisclosure />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -13,7 +13,6 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { fromCheckboxValue, toCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@ -27,29 +26,30 @@ import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type CheckboxFieldProps = {
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const CheckboxField = ({
|
||||
field,
|
||||
recipient,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: CheckboxFieldProps) => {
|
||||
export const CheckboxField = ({ field, onSignField, onUnsignField }: CheckboxFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||
|
||||
const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
||||
const parsedFieldMeta = ZCheckboxFieldMeta.parse(
|
||||
field.fieldMeta ?? {
|
||||
type: 'checkbox',
|
||||
values: [{ id: 1, checked: false, value: '' }],
|
||||
},
|
||||
);
|
||||
|
||||
const values = parsedFieldMeta.values?.map((item) => ({
|
||||
...item,
|
||||
@ -122,7 +122,9 @@ export const CheckboxField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -151,7 +153,7 @@ export const CheckboxField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -183,28 +185,25 @@ export const CheckboxField = ({
|
||||
...checkedValues,
|
||||
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||
];
|
||||
|
||||
await removeSignedFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
|
||||
if (isLengthConditionMet) {
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: toCheckboxValue(checkedValues),
|
||||
isBase64: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updatedValues = checkedValues.filter(
|
||||
(v) => v !== item.value && v !== `empty-value-${item.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
await removeSignedFieldWithToken({
|
||||
setCheckedValues(updatedValues);
|
||||
|
||||
await removeSignedFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
});
|
||||
|
||||
if (updatedValues.length > 0) {
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: toCheckboxValue(updatedValues),
|
||||
isBase64: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@ -216,7 +215,6 @@ export const CheckboxField = ({
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCheckedValues(updatedValues);
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
};
|
||||
|
||||
@ -16,20 +16,21 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { ZDateFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type DateFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
dateFormat?: string | null;
|
||||
timezone?: string | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
@ -38,17 +39,17 @@ export type DateFieldProps = {
|
||||
|
||||
export const DateField = ({
|
||||
field,
|
||||
recipient,
|
||||
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: DateFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
|
||||
@ -59,12 +60,13 @@ export const DateField = ({
|
||||
isPending: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const safeFieldMeta = ZDateFieldMeta.safeParse(field.fieldMeta);
|
||||
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
|
||||
|
||||
const isDifferentTime = field.inserted && localDateString !== field.customText;
|
||||
|
||||
const tooltipText = _(
|
||||
msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`,
|
||||
);
|
||||
@ -97,7 +99,9 @@ export const DateField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -123,7 +127,7 @@ export const DateField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -150,9 +154,21 @@ export const DateField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{localDateString}
|
||||
</p>
|
||||
<div className="flex h-full w-full items-center">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
{
|
||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||
'text-center':
|
||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||
},
|
||||
)}
|
||||
>
|
||||
{localDateString}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
|
||||
@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@ -30,23 +29,19 @@ import {
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type DropdownFieldProps = {
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const DropdownField = ({
|
||||
field,
|
||||
recipient,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: DropdownFieldProps) => {
|
||||
export const DropdownField = ({ field, onSignField, onUnsignField }: DropdownFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@ -103,7 +98,9 @@ export const DropdownField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -134,7 +131,7 @@ export const DropdownField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@ -11,32 +11,34 @@ import { Loader } from 'lucide-react';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { ZEmailFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type EmailFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const EmailField = ({ field, recipient, onSignField, onUnsignField }: EmailFieldProps) => {
|
||||
export const EmailField = ({ field, onSignField, onUnsignField }: EmailFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { email: providedEmail } = useRequiredSigningContext();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@ -48,6 +50,9 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
|
||||
isPending: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const safeFieldMeta = ZEmailFieldMeta.safeParse(field.fieldMeta);
|
||||
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||
@ -81,7 +86,9 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -107,7 +114,7 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -128,9 +135,21 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
<div className="flex h-full w-full items-center">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
{
|
||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||
'text-center':
|
||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||
},
|
||||
)}
|
||||
>
|
||||
{field.customText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
|
||||
@ -1,19 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { type Field, FieldType, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { type Field, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -21,8 +24,11 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { AssistantConfirmationDialog } from './assistant/assistant-confirmation-dialog';
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { SignDialog } from './sign-dialog';
|
||||
|
||||
@ -32,6 +38,8 @@ export type SigningFormProps = {
|
||||
fields: Field[];
|
||||
redirectUrl?: string | null;
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
setSelectedSignerId?: (id: number | null) => void;
|
||||
};
|
||||
|
||||
export const SigningForm = ({
|
||||
@ -40,19 +48,35 @@ export const SigningForm = ({
|
||||
fields,
|
||||
redirectUrl,
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
setSelectedSignerId,
|
||||
}: SigningFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
const assistantSignersId = useId();
|
||||
|
||||
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
|
||||
useRequiredSigningContext();
|
||||
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
||||
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
||||
defaultValues: {
|
||||
selectedSignerId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSubmit, formState } = useForm();
|
||||
|
||||
// Keep the loading state going if successful since the redirect may take some time.
|
||||
@ -67,7 +91,11 @@ export const SigningForm = ({
|
||||
|
||||
const uninsertedFields = useMemo(() => {
|
||||
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
|
||||
}, [fields]);
|
||||
}, [fieldsRequiringValidation]);
|
||||
|
||||
const uninsertedRecipientFields = useMemo(() => {
|
||||
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
||||
}, [fieldsRequiringValidation, recipient]);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
setValidateUninsertedFields(true);
|
||||
@ -88,12 +116,31 @@ export const SigningForm = ({
|
||||
}
|
||||
|
||||
await completeDocument();
|
||||
};
|
||||
|
||||
// Reauth is currently not required for completing the document.
|
||||
// await executeActionAuthProcedure({
|
||||
// onReauthFormSubmit: completeDocument,
|
||||
// actionTarget: 'DOCUMENT',
|
||||
// });
|
||||
const onAssistantFormSubmit = () => {
|
||||
if (uninsertedRecipientFields.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConfirmationDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAssistantConfirmDialogSubmit = async () => {
|
||||
setIsAssistantSubmitting(true);
|
||||
|
||||
try {
|
||||
await completeDocument();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while completing the document. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
setIsAssistantSubmitting(false);
|
||||
setIsConfirmationDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
||||
@ -113,7 +160,7 @@ export const SigningForm = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
<div
|
||||
className={cn(
|
||||
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
|
||||
{
|
||||
@ -121,7 +168,6 @@ export const SigningForm = ({
|
||||
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !session,
|
||||
},
|
||||
)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
@ -129,17 +175,13 @@ export const SigningForm = ({
|
||||
</FieldToolTip>
|
||||
)}
|
||||
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex flex-1 flex-col')}>
|
||||
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
|
||||
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
|
||||
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
|
||||
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
|
||||
</h3>
|
||||
|
||||
{recipient.role === RecipientRole.VIEWER ? (
|
||||
@ -176,91 +218,191 @@ export const SigningForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : recipient.role === RecipientRole.ASSISTANT ? (
|
||||
<>
|
||||
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>
|
||||
Complete the fields for the following signers. Once reviewed, they will inform
|
||||
you if any modifications are needed.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border my-4" />
|
||||
|
||||
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
|
||||
<Controller
|
||||
name="selectedSignerId"
|
||||
control={assistantForm.control}
|
||||
rules={{ required: 'Please select a signer' }}
|
||||
render={({ field }) => (
|
||||
<RadioGroup
|
||||
className="gap-0 space-y-3 shadow-none"
|
||||
value={field.value?.toString()}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
setSelectedSignerId?.(Number(value));
|
||||
}}
|
||||
>
|
||||
{allRecipients
|
||||
.filter((r) => r.fields.length > 0)
|
||||
.map((r) => (
|
||||
<div
|
||||
key={`${assistantSignersId}-${r.id}`}
|
||||
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
id={`${assistantSignersId}-${r.id}`}
|
||||
value={r.id.toString()}
|
||||
className="after:absolute after:inset-0"
|
||||
/>
|
||||
|
||||
<div className="grid grow gap-1">
|
||||
<Label
|
||||
className="inline-flex items-start"
|
||||
htmlFor={`${assistantSignersId}-${r.id}`}
|
||||
>
|
||||
{r.name}
|
||||
|
||||
{r.id === recipient.id && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{_(msg`(You)`)}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
||||
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
loading={isAssistantSubmitting}
|
||||
disabled={isAssistantSubmitting || uninsertedRecipientFields.length > 0}
|
||||
>
|
||||
{isAssistantSubmitting ? <Trans>Submitting...</Trans> : <Trans>Continue</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AssistantConfirmationDialog
|
||||
hasUninsertedFields={uninsertedFields.length > 0}
|
||||
isOpen={isConfirmationDialogOpen}
|
||||
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
||||
onConfirm={handleAssistantConfirmDialogSubmit}
|
||||
isSubmitting={isAssistantSubmitting}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Please review the document before signing.</Trans>
|
||||
</p>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
||||
<Trans>Please review the document before approving.</Trans>
|
||||
) : (
|
||||
<Trans>Please review the document before signing.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
{hasSignatureField && (
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (signatureValid) {
|
||||
setSignature(value);
|
||||
}
|
||||
}}
|
||||
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (signatureValid) {
|
||||
setSignature(value);
|
||||
}
|
||||
}}
|
||||
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
{!signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={recipient.role}
|
||||
disabled={!isRecipientsTurn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={recipient.role}
|
||||
disabled={!isRecipientsTurn}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@ -22,26 +21,22 @@ import type {
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type InitialsFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const InitialsField = ({
|
||||
field,
|
||||
recipient,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
}: InitialsFieldProps) => {
|
||||
export const InitialsField = ({ field, onSignField, onUnsignField }: InitialsFieldProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { fullName } = useRequiredSigningContext();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
const initials = extractInitials(fullName);
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@ -87,7 +82,9 @@ export const InitialsField = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@ -11,13 +11,14 @@ import { Loader } from 'lucide-react';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { type Recipient } from '@documenso/prisma/client';
|
||||
import { ZNameFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
TSignFieldWithTokenMutationSchema,
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@ -26,16 +27,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type NameFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const NameField = ({ field, recipient, onSignField, onUnsignField }: NameFieldProps) => {
|
||||
export const NameField = ({ field, onSignField, onUnsignField }: NameFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { _ } = useLingui();
|
||||
@ -43,6 +44,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
|
||||
const { fullName: providedFullName, setFullName: setProvidedFullName } =
|
||||
useRequiredSigningContext();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||
|
||||
@ -56,13 +58,16 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
isPending: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const safeFieldMeta = ZNameFieldMeta.safeParse(field.fieldMeta);
|
||||
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const [showFullNameModal, setShowFullNameModal] = useState(false);
|
||||
const [localFullName, setLocalFullName] = useState('');
|
||||
|
||||
const onPreSign = () => {
|
||||
if (!providedFullName) {
|
||||
if (!providedFullName && !isAssistantMode) {
|
||||
setShowFullNameModal(true);
|
||||
return false;
|
||||
}
|
||||
@ -85,9 +90,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
|
||||
const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
|
||||
try {
|
||||
const value = name || providedFullName;
|
||||
const value = name || providedFullName || '';
|
||||
|
||||
if (!value) {
|
||||
if (!value && !isAssistantMode) {
|
||||
setShowFullNameModal(true);
|
||||
return;
|
||||
}
|
||||
@ -119,7 +124,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -145,7 +152,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -172,9 +179,21 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
<div className="flex h-full w-full items-center">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
{
|
||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||
'text-center':
|
||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||
},
|
||||
)}
|
||||
>
|
||||
{field.customText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
|
||||
|
||||
@ -13,7 +13,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@ -27,6 +26,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
type ValidationErrors = {
|
||||
@ -39,21 +39,32 @@ type ValidationErrors = {
|
||||
|
||||
export type NumberFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const NumberField = ({ field, recipient, onSignField, onUnsignField }: NumberFieldProps) => {
|
||||
export const NumberField = ({ field, onSignField, onUnsignField }: NumberFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [showRadioModal, setShowRadioModal] = useState(false);
|
||||
const [showNumberModal, setShowNumberModal] = useState(false);
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isPending: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const safeFieldMeta = ZNumberFieldMeta.safeParse(field.fieldMeta);
|
||||
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null;
|
||||
const isReadOnly = parsedFieldMeta?.readOnly;
|
||||
const defaultValue = parsedFieldMeta?.value;
|
||||
const [localNumber, setLocalNumber] = useState(
|
||||
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
|
||||
@ -71,16 +82,6 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isPending: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value;
|
||||
setLocalNumber(text);
|
||||
@ -104,7 +105,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
};
|
||||
|
||||
const onDialogSignClick = () => {
|
||||
setShowRadioModal(false);
|
||||
setShowNumberModal(false);
|
||||
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
@ -147,14 +148,20 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onPreSign = () => {
|
||||
setShowRadioModal(true);
|
||||
if (isAssistantMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
setShowNumberModal(true);
|
||||
|
||||
if (localNumber && parsedFieldMeta) {
|
||||
const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true);
|
||||
@ -192,23 +199,23 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRadioModal) {
|
||||
if (!showNumberModal) {
|
||||
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
|
||||
setErrors(initialErrors);
|
||||
}
|
||||
}, [showRadioModal]);
|
||||
}, [showNumberModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(!field.inserted && defaultValue && localNumber) ||
|
||||
(!field.inserted && isReadOnly && defaultValue)
|
||||
(!field.inserted && parsedFieldMeta?.readOnly && defaultValue)
|
||||
) {
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
@ -221,8 +228,8 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
|
||||
if (parsedFieldMeta?.label) {
|
||||
fieldDisplayName =
|
||||
parsedFieldMeta.label.length > 10
|
||||
? parsedFieldMeta.label.substring(0, 10) + '...'
|
||||
parsedFieldMeta.label.length > 20
|
||||
? parsedFieldMeta.label.substring(0, 20) + '...'
|
||||
: parsedFieldMeta.label;
|
||||
}
|
||||
|
||||
@ -234,7 +241,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
onPreSign={onPreSign}
|
||||
onSign={onSign}
|
||||
onRemove={onRemove}
|
||||
type="Signature"
|
||||
type="Number"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
@ -260,12 +267,24 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{field.customText}
|
||||
</p>
|
||||
<div className="flex h-full w-full items-center">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
{
|
||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||
'text-center':
|
||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||
},
|
||||
)}
|
||||
>
|
||||
{field.customText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>
|
||||
<Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>}
|
||||
@ -321,7 +340,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowRadioModal(false);
|
||||
setShowNumberModal(false);
|
||||
setLocalNumber('');
|
||||
}}
|
||||
>
|
||||
|
||||
@ -12,11 +12,12 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
|
||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { DocumentAuthProvider } from './document-auth-provider';
|
||||
import { NoLongerAvailable } from './no-longer-available';
|
||||
@ -43,14 +44,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
|
||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||
|
||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||
const [document, recipient, fields, completedFields] = await Promise.all([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
userId: user?.id,
|
||||
requireAccessAuth: false,
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getCompletedFieldsForToken({ token }),
|
||||
]);
|
||||
|
||||
@ -63,12 +64,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const recipientWithFields = { ...recipient, fields };
|
||||
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
return redirect(`/sign/${token}/waiting`);
|
||||
}
|
||||
|
||||
const allRecipients =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? await getRecipientsForAssistant({
|
||||
token,
|
||||
})
|
||||
: [];
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
@ -153,11 +163,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
user={user}
|
||||
>
|
||||
<SigningPageView
|
||||
recipient={recipient}
|
||||
recipient={recipientWithFields}
|
||||
document={document}
|
||||
fields={fields}
|
||||
completedFields={completedFields}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
|
||||
@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@ -24,18 +23,19 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
export type RadioFieldProps = {
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const RadioField = ({ field, recipient, onSignField, onUnsignField }: RadioFieldProps) => {
|
||||
export const RadioField = ({ field, onSignField, onUnsignField }: RadioFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@ -99,7 +99,9 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -126,7 +128,7 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the signature.`),
|
||||
description: _(msg`An error occurred while removing the selection.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { type PropsWithChildren, createContext, useContext } from 'react';
|
||||
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
|
||||
export interface RecipientContextValue {
|
||||
/**
|
||||
* The recipient who is currently signing the document.
|
||||
* In regular mode, this is the actual signer.
|
||||
* In assistant mode, this is the recipient who is helping fill out the document.
|
||||
*/
|
||||
recipient: Recipient | RecipientWithFields;
|
||||
|
||||
/**
|
||||
* Only present in assistant mode.
|
||||
* The recipient on whose behalf we're filling out the document.
|
||||
*/
|
||||
targetSigner: RecipientWithFields | null;
|
||||
|
||||
/**
|
||||
* Whether we're in assistant mode (one recipient filling out for another)
|
||||
*/
|
||||
isAssistantMode: boolean;
|
||||
}
|
||||
|
||||
const RecipientContext = createContext<RecipientContextValue | null>(null);
|
||||
|
||||
export interface RecipientProviderProps extends PropsWithChildren {
|
||||
recipient: Recipient | RecipientWithFields;
|
||||
targetSigner?: RecipientWithFields | null;
|
||||
}
|
||||
|
||||
export const RecipientProvider = ({
|
||||
children,
|
||||
recipient,
|
||||
targetSigner = null,
|
||||
}: RecipientProviderProps) => {
|
||||
// console.log({
|
||||
// recipient,
|
||||
// targetSigner,
|
||||
// isAssistantMode: !!targetSigner,
|
||||
// });
|
||||
return (
|
||||
<RecipientContext.Provider
|
||||
value={{
|
||||
recipient,
|
||||
targetSigner,
|
||||
isAssistantMode: !!targetSigner,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecipientContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useRecipientContext() {
|
||||
const context = useContext(RecipientContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useRecipientContext must be used within a RecipientProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@ -43,9 +43,10 @@ type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;
|
||||
export interface RejectDocumentDialogProps {
|
||||
document: Pick<Document, 'id'>;
|
||||
token: string;
|
||||
onRejected?: (reason: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function RejectDocumentDialog({ document, token }: RejectDocumentDialogProps) {
|
||||
export function RejectDocumentDialog({ document, token, onRejected }: RejectDocumentDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@ -79,7 +80,11 @@ export function RejectDocumentDialog({ document, token }: RejectDocumentDialogPr
|
||||
|
||||
setIsOpen(false);
|
||||
|
||||
router.push(`/sign/${token}/rejected`);
|
||||
if (onRejected) {
|
||||
await onRejected(reason);
|
||||
} else {
|
||||
router.push(`/sign/${token}/rejected`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
@ -58,62 +59,88 @@ export const SignDialog = ({
|
||||
loading={isSubmitting}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
|
||||
{match({ isComplete, role })
|
||||
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
||||
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
||||
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
||||
<Trans>Mark as viewed</Trans>
|
||||
))
|
||||
.with({ isComplete: true }, () => <Trans>Complete</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
<div className="text-foreground text-xl font-semibold">
|
||||
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
|
||||
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
|
||||
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{role === RecipientRole.VIEWER && (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
)}
|
||||
{role === RecipientRole.SIGNER && (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete signing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete signing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
)}
|
||||
{role === RecipientRole.APPROVER && (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete approving{' '}
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
"{documentTitle}"
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete approving{' '}
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
"{documentTitle}"
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
)}
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SigningDisclosure className="mt-4" />
|
||||
@ -138,9 +165,13 @@ export const SignDialog = ({
|
||||
loading={isSubmitting}
|
||||
onClick={onSignatureComplete}
|
||||
>
|
||||
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
||||
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
||||
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
|
||||
{match(role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
@ -11,7 +11,6 @@ import { Loader } from 'lucide-react';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { type Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@ -28,12 +27,12 @@ import { SigningDisclosure } from '~/components/general/signing-disclosure';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
|
||||
export type SignatureFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
typedSignatureEnabled?: boolean;
|
||||
@ -41,15 +40,14 @@ export type SignatureFieldProps = {
|
||||
|
||||
export const SignatureField = ({
|
||||
field,
|
||||
recipient,
|
||||
onSignField,
|
||||
onUnsignField,
|
||||
typedSignatureEnabled,
|
||||
}: SignatureFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient } = useRecipientContext();
|
||||
|
||||
const signatureRef = useRef<HTMLParagraphElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -46,6 +46,7 @@ export type SignatureFieldProps = {
|
||||
| 'Email'
|
||||
| 'Name'
|
||||
| 'Signature'
|
||||
| 'Text'
|
||||
| 'Radio'
|
||||
| 'Dropdown'
|
||||
| 'Number'
|
||||
@ -181,6 +182,23 @@ export const SigningFieldContainer = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
|
||||
field.fieldMeta?.label && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
|
||||
{
|
||||
'bg-foreground/5 border-border border': !field.inserted,
|
||||
},
|
||||
{
|
||||
'bg-documenso-200 border-primary border': field.inserted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{field.fieldMeta.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</FieldRootContainer>
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@ -13,9 +17,10 @@ import {
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
@ -32,16 +37,18 @@ import { InitialsField } from './initials-field';
|
||||
import { NameField } from './name-field';
|
||||
import { NumberField } from './number-field';
|
||||
import { RadioField } from './radio-field';
|
||||
import { RecipientProvider } from './recipient-context';
|
||||
import { RejectDocumentDialog } from './reject-document-dialog';
|
||||
import { SignatureField } from './signature-field';
|
||||
import { TextField } from './text-field';
|
||||
|
||||
export type SigningPageViewProps = {
|
||||
document: DocumentAndSender;
|
||||
recipient: Recipient;
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
completedFields: CompletedField[];
|
||||
isRecipientsTurn: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
};
|
||||
|
||||
export const SigningPageView = ({
|
||||
@ -50,9 +57,12 @@ export const SigningPageView = ({
|
||||
fields,
|
||||
completedFields,
|
||||
isRecipientsTurn,
|
||||
allRecipients = [],
|
||||
}: SigningPageViewProps) => {
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||
|
||||
const shouldUseTeamDetails =
|
||||
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
|
||||
|
||||
@ -64,153 +74,168 @@ export const SigningPageView = ({
|
||||
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
|
||||
}
|
||||
|
||||
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="text-muted-foreground truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to view this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to view 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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RejectDocumentDialog document={document} token={recipient.token} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||
<Card
|
||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||
gradient
|
||||
<RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={documentData.id}
|
||||
documentData={documentData}
|
||||
document={document}
|
||||
password={documentMeta?.password}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||
<SigningForm
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
/>
|
||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="text-muted-foreground truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to view this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to view 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>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.ASSISTANT, () =>
|
||||
document.teamId && !shouldUseTeamDetails ? (
|
||||
<Trans>
|
||||
on behalf of "{document.team?.name}" has invited you to assist this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to assist this document</Trans>
|
||||
),
|
||||
)
|
||||
.otherwise(() => null)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RejectDocumentDialog document={document} token={recipient.token} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentReadOnlyFields fields={completedFields} />
|
||||
|
||||
<AutoSign recipient={recipient} fields={fields} />
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
<SignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||
<Card
|
||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={documentData.id}
|
||||
documentData={documentData}
|
||||
document={document}
|
||||
password={documentMeta?.password}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
<InitialsField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.NAME, () => (
|
||||
<NameField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.TEXT, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <TextField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.NUMBER, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <NumberField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.RADIO, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <RadioField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.CHECKBOX, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <CheckboxField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.with(FieldType.DROPDOWN, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <DropdownField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
||||
})
|
||||
.otherwise(() => null),
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||
<SigningForm
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
setSelectedSignerId={setSelectedSignerId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentReadOnlyFields fields={completedFields} />
|
||||
|
||||
{recipient.role !== RecipientRole.ASSISTANT && (
|
||||
<AutoSign recipient={recipient} fields={fields} />
|
||||
)}
|
||||
</ElementVisible>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{fields
|
||||
.filter(
|
||||
(field) =>
|
||||
recipient.role !== RecipientRole.ASSISTANT ||
|
||||
field.recipientId === selectedSigner?.id,
|
||||
)
|
||||
.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
<SignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => <InitialsField key={field.id} field={field} />)
|
||||
.with(FieldType.NAME, () => <NameField key={field.id} field={field} />)
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.EMAIL, () => <EmailField key={field.id} field={field} />)
|
||||
.with(FieldType.TEXT, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <TextField key={field.id} field={fieldWithMeta} />;
|
||||
})
|
||||
.with(FieldType.NUMBER, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <NumberField key={field.id} field={fieldWithMeta} />;
|
||||
})
|
||||
.with(FieldType.RADIO, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <RadioField key={field.id} field={fieldWithMeta} />;
|
||||
})
|
||||
.with(FieldType.CHECKBOX, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <CheckboxField key={field.id} field={fieldWithMeta} />;
|
||||
})
|
||||
.with(FieldType.DROPDOWN, () => {
|
||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||
...field,
|
||||
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
|
||||
};
|
||||
return <DropdownField key={field.id} field={fieldWithMeta} />;
|
||||
})
|
||||
.otherwise(() => null),
|
||||
)}
|
||||
</ElementVisible>
|
||||
</div>
|
||||
</RecipientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -13,7 +13,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { ZTextFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@ -27,26 +26,31 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRecipientContext } from './recipient-context';
|
||||
import { SigningFieldContainer } from './signing-field-container';
|
||||
|
||||
type ValidationErrors = {
|
||||
required: string[];
|
||||
characterLimit: string[];
|
||||
};
|
||||
|
||||
export type TextFieldProps = {
|
||||
field: FieldWithSignatureAndFieldMeta;
|
||||
recipient: Recipient;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => {
|
||||
export const TextField = ({ field, onSignField, onUnsignField }: TextFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const initialErrors: Record<string, string[]> = {
|
||||
const initialErrors: ValidationErrors = {
|
||||
required: [],
|
||||
characterLimit: [],
|
||||
};
|
||||
|
||||
const [errors, setErrors] = useState(initialErrors);
|
||||
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
|
||||
|
||||
@ -62,7 +66,8 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
isPending: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null;
|
||||
const safeFieldMeta = ZTextFieldMeta.safeParse(field.fieldMeta);
|
||||
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
const shouldAutoSignField =
|
||||
@ -165,7 +170,9 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while signing the document.`),
|
||||
description: isAssistantMode
|
||||
? _(msg`An error occurred while signing as assistant.`)
|
||||
: _(msg`An error occurred while signing the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -193,7 +200,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while removing the text.`),
|
||||
description: _(msg`An error occurred while removing the field.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -233,7 +240,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
onPreSign={onPreSign}
|
||||
onSign={onSign}
|
||||
onRemove={onRemove}
|
||||
type="Signature"
|
||||
type="Text"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
@ -261,11 +268,23 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{field.customText.length < 20
|
||||
? field.customText
|
||||
: field.customText.substring(0, 15) + '...'}
|
||||
</p>
|
||||
<div className="flex h-full w-full items-center">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
{
|
||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||
'text-center':
|
||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||
},
|
||||
)}
|
||||
>
|
||||
{field.customText.length < 20
|
||||
? field.customText
|
||||
: field.customText.substring(0, 20) + '...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
|
||||
@ -281,6 +300,10 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
|
||||
className={cn('mt-2 w-full rounded-md', {
|
||||
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
|
||||
userInputHasErrors,
|
||||
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||
'text-center':
|
||||
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||
})}
|
||||
value={localText}
|
||||
onChange={handleTextChange}
|
||||
|
||||
@ -12,7 +12,7 @@ export type EmbedDocumentCompletedPageProps = {
|
||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||
console.log({ signature });
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
<Trans>Document Completed!</Trans>
|
||||
</h3>
|
||||
|
||||
@ -49,7 +49,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
hidePoweredBy?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
allowWhiteLabelling?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedDirectTemplateClientPage = ({
|
||||
@ -60,7 +60,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
fields,
|
||||
metadata,
|
||||
hidePoweredBy = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
allowWhiteLabelling = false,
|
||||
}: EmbedDirectTemplateClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -288,7 +288,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (isPlatformOrEnterprise) {
|
||||
if (allowWhiteLabelling) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
@ -349,7 +349,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
||||
@ -360,19 +360,34 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -417,40 +432,42 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
{hasSignatureField && (
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
allowTypedSignature={Boolean(
|
||||
metadata &&
|
||||
'typedSignatureEnabled' in metadata &&
|
||||
metadata.typedSignatureEnabled,
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
allowTypedSignature={Boolean(
|
||||
metadata &&
|
||||
'typedSignatureEnabled' in metadata &&
|
||||
metadata.typedSignatureEnabled,
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -485,7 +502,6 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields
|
||||
recipient={recipient}
|
||||
fields={localFields}
|
||||
metadata={metadata}
|
||||
onSignField={onSignField}
|
||||
|
||||
@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
@ -13,6 +14,7 @@ import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
|
||||
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
|
||||
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
|
||||
|
||||
import { EmbedAuthenticateView } from '../../authenticate';
|
||||
import { EmbedPaywall } from '../../paywall';
|
||||
@ -54,12 +56,16 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
|
||||
isDocumentPlatform(template),
|
||||
isUserEnterprise({
|
||||
userId: template.userId,
|
||||
teamId: template.teamId ?? undefined,
|
||||
}),
|
||||
isUserCommunityPlan({
|
||||
userId: template.userId,
|
||||
teamId: template.teamId ?? undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
||||
@ -96,16 +102,20 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
||||
recipient={recipient}
|
||||
user={user}
|
||||
>
|
||||
<EmbedDirectTemplateClientPage
|
||||
token={token}
|
||||
updatedAt={template.updatedAt}
|
||||
documentData={template.templateDocumentData}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
metadata={template.templateMeta}
|
||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||
/>
|
||||
<RecipientProvider recipient={recipient}>
|
||||
<EmbedDirectTemplateClientPage
|
||||
token={token}
|
||||
updatedAt={template.updatedAt}
|
||||
documentData={template.templateDocumentData}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
metadata={template.templateMeta}
|
||||
hidePoweredBy={
|
||||
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
||||
}
|
||||
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
||||
/>
|
||||
</RecipientProvider>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
);
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
|
||||
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
|
||||
import { type Field, FieldType } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type {
|
||||
@ -33,7 +33,6 @@ import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
|
||||
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
|
||||
|
||||
export type EmbedDocumentFieldsProps = {
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
@ -41,7 +40,6 @@ export type EmbedDocumentFieldsProps = {
|
||||
};
|
||||
|
||||
export const EmbedDocumentFields = ({
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
onSignField,
|
||||
@ -55,7 +53,6 @@ export const EmbedDocumentFields = ({
|
||||
<SignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
@ -65,7 +62,6 @@ export const EmbedDocumentFields = ({
|
||||
<InitialsField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -74,7 +70,6 @@ export const EmbedDocumentFields = ({
|
||||
<NameField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -83,7 +78,6 @@ export const EmbedDocumentFields = ({
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
dateFormat={metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
@ -94,7 +88,6 @@ export const EmbedDocumentFields = ({
|
||||
<EmailField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -109,7 +102,6 @@ export const EmbedDocumentFields = ({
|
||||
<TextField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -125,7 +117,6 @@ export const EmbedDocumentFields = ({
|
||||
<NumberField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -141,7 +132,6 @@ export const EmbedDocumentFields = ({
|
||||
<RadioField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -157,7 +147,6 @@ export const EmbedDocumentFields = ({
|
||||
<CheckboxField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@ -173,7 +162,6 @@ export const EmbedDocumentFields = ({
|
||||
<DropdownField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
|
||||
40
apps/web/src/app/embed/rejected.tsx
Normal file
40
apps/web/src/app/embed/rejected.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { XCircle } from 'lucide-react';
|
||||
|
||||
import type { Signature } from '@documenso/prisma/client';
|
||||
|
||||
export type EmbedDocumentRejectedPageProps = {
|
||||
name?: string;
|
||||
signature?: Signature;
|
||||
};
|
||||
|
||||
export const EmbedDocumentRejected = ({ name }: EmbedDocumentRejectedPageProps) => {
|
||||
return (
|
||||
<div className="embed--DocumentRejected relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<XCircle className="text-destructive h-10 w-10" />
|
||||
|
||||
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Document Rejected</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="text-destructive mt-4 flex items-center text-center text-sm">
|
||||
<Trans>You have rejected this document</Trans>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
|
||||
<Trans>
|
||||
The document owner has been notified of your decision. They may contact you with further
|
||||
instructions if necessary.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
|
||||
<Trans>No further action is required from you at this time.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { useEffect, useId, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
@ -9,8 +9,15 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
|
||||
import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
|
||||
import {
|
||||
type DocumentData,
|
||||
type Field,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -19,15 +26,19 @@ import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
|
||||
import { RejectDocumentDialog } from '~/app/(signing)/sign/[token]/reject-document-dialog';
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
|
||||
import { EmbedClientLoading } from '../../client-loading';
|
||||
import { EmbedDocumentCompleted } from '../../completed';
|
||||
import { EmbedDocumentFields } from '../../document-fields';
|
||||
import { EmbedDocumentRejected } from '../../rejected';
|
||||
import { injectCss } from '../../util';
|
||||
import { ZSignDocumentEmbedDataSchema } from './schema';
|
||||
|
||||
@ -35,12 +46,13 @@ export type EmbedSignDocumentClientPageProps = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
documentData: DocumentData;
|
||||
recipient: Recipient;
|
||||
recipient: RecipientWithFields;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
allowWhitelabelling?: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
};
|
||||
|
||||
export const EmbedSignDocumentClientPage = ({
|
||||
@ -52,7 +64,8 @@ export const EmbedSignDocumentClientPage = ({
|
||||
metadata,
|
||||
isCompleted,
|
||||
hidePoweredBy = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
allowWhitelabelling = false,
|
||||
allRecipients = [],
|
||||
}: EmbedSignDocumentClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -70,17 +83,26 @@ export const EmbedSignDocumentClientPage = ({
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
||||
const [hasRejectedDocument, setHasRejectedDocument] = useState(
|
||||
recipient.signingStatus === SigningStatus.REJECTED,
|
||||
);
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
||||
allRecipients.length > 0 ? allRecipients[0].id : null,
|
||||
);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
|
||||
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
|
||||
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
|
||||
|
||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
fields.filter((field) => !field.inserted),
|
||||
fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
|
||||
fields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
@ -89,6 +111,8 @@ export const EmbedSignDocumentClientPage = ({
|
||||
|
||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
const assistantSignersId = useId();
|
||||
|
||||
const onNextFieldClick = () => {
|
||||
validateFieldsInserted(fields);
|
||||
|
||||
@ -150,6 +174,25 @@ export const EmbedSignDocumentClientPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentRejected = (reason: string) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-rejected',
|
||||
data: {
|
||||
token,
|
||||
documentId,
|
||||
recipientId: recipient.id,
|
||||
reason,
|
||||
},
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasRejectedDocument(true);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
@ -163,12 +206,13 @@ export const EmbedSignDocumentClientPage = ({
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (isPlatformOrEnterprise) {
|
||||
if (allowWhitelabelling) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
@ -197,6 +241,10 @@ export const EmbedSignDocumentClientPage = ({
|
||||
}
|
||||
}, [hasFinishedInit, hasDocumentLoaded]);
|
||||
|
||||
if (hasRejectedDocument) {
|
||||
return <EmbedDocumentRejected name={fullName} />;
|
||||
}
|
||||
|
||||
if (hasCompletedDocument) {
|
||||
return (
|
||||
<EmbedDocumentCompleted
|
||||
@ -214,164 +262,263 @@ export const EmbedSignDocumentClientPage = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
<RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<LazyPDFViewer
|
||||
documentData={documentData}
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
{allowDocumentRejection && (
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<RejectDocumentDialog
|
||||
document={{ id: documentId }}
|
||||
token={token}
|
||||
onRejected={onDocumentRejected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="embed--DocumentViewer flex-1">
|
||||
<LazyPDFViewer
|
||||
documentData={documentData}
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
{/* Header */}
|
||||
<div className="embed--DocumentWidgetHeader">
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
{isAssistantMode ? (
|
||||
<Trans>Assist with signing</Trans>
|
||||
) : (
|
||||
<Trans>Sign document</Trans>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
>
|
||||
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
) : pendingFields.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
>
|
||||
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
disabled={
|
||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||
}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Sign the document to complete the process.</Trans>
|
||||
</p>
|
||||
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{isAssistantMode ? (
|
||||
<Trans>Help complete the document for other signers.</Trans>
|
||||
) : (
|
||||
<Trans>Sign the document to complete the process.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
</div>
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
{/* Form */}
|
||||
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
{isAssistantMode && (
|
||||
<div>
|
||||
<Label>
|
||||
<Trans>Signing for</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
disabled={isNameLocked}
|
||||
value={fullName}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="dark:bg-background border-border mt-2 rounded-2xl border bg-white p-3">
|
||||
<RadioGroup
|
||||
className="gap-0 space-y-3 shadow-none"
|
||||
value={selectedSignerId?.toString()}
|
||||
onValueChange={(value) => setSelectedSignerId(Number(value))}
|
||||
>
|
||||
{allRecipients
|
||||
.filter((r) => r.fields.length > 0)
|
||||
.map((r) => (
|
||||
<div
|
||||
key={`${assistantSignersId}-${r.id}`}
|
||||
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
id={`${assistantSignersId}-${r.id}`}
|
||||
value={r.id.toString()}
|
||||
className="after:absolute after:inset-0"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
<div className="grid grow gap-1">
|
||||
<Label
|
||||
className="inline-flex items-start"
|
||||
htmlFor={`${assistantSignersId}-${r.id}`}
|
||||
>
|
||||
{r.name}
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="bg-background mt-2"
|
||||
value={email}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
allowTypedSignature={Boolean(
|
||||
metadata &&
|
||||
'typedSignatureEnabled' in metadata &&
|
||||
metadata.typedSignatureEnabled,
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
{r.id === recipient.id && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{_(msg`(You)`)}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
||||
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAssistantMode && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
disabled={isNameLocked}
|
||||
value={fullName}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="bg-background mt-2"
|
||||
value={email}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasSignatureField && (
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
allowTypedSignature={Boolean(
|
||||
metadata &&
|
||||
'typedSignatureEnabled' in metadata &&
|
||||
metadata.typedSignatureEnabled,
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
||||
|
||||
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
||||
{pendingFields.length > 0 ? (
|
||||
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
||||
<Trans>Next</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="col-start-2"
|
||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
||||
{pendingFields.length > 0 ? (
|
||||
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
||||
<Trans>Next</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
||||
disabled={
|
||||
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||
}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<Logo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</RecipientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,23 +2,27 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
|
||||
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
|
||||
|
||||
import { EmbedAuthenticateView } from '../../authenticate';
|
||||
import { EmbedPaywall } from '../../paywall';
|
||||
import { EmbedWaitingForTurn } from '../../waiting-for-turn';
|
||||
import { EmbedSignDocumentClientPage } from './client';
|
||||
|
||||
export type EmbedSignDocumentPageProps = {
|
||||
@ -59,12 +63,16 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
return <EmbedPaywall />;
|
||||
}
|
||||
|
||||
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
|
||||
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
|
||||
isDocumentPlatform(document),
|
||||
isUserEnterprise({
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
}),
|
||||
isUserCommunityPlan({
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
@ -85,6 +93,19 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
);
|
||||
}
|
||||
|
||||
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
|
||||
|
||||
if (!isRecipientsTurnToSign) {
|
||||
return <EmbedWaitingForTurn />;
|
||||
}
|
||||
|
||||
const allRecipients =
|
||||
recipient.role === RecipientRole.ASSISTANT
|
||||
? await getRecipientsForAssistant({
|
||||
token,
|
||||
})
|
||||
: [];
|
||||
|
||||
const team = document.teamId
|
||||
? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null)
|
||||
: null;
|
||||
@ -110,8 +131,11 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
|
||||
fields={fields}
|
||||
metadata={document.documentMeta}
|
||||
isCompleted={document.status === DocumentStatus.COMPLETED}
|
||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||
hidePoweredBy={
|
||||
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
||||
}
|
||||
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
||||
allRecipients={allRecipients}
|
||||
/>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
|
||||
@ -13,4 +13,5 @@ export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
|
||||
.optional()
|
||||
.transform((value) => value || undefined),
|
||||
lockName: z.boolean().optional().default(false),
|
||||
allowDocumentRejection: z.boolean().optional(),
|
||||
});
|
||||
|
||||
48
apps/web/src/app/embed/waiting-for-turn.tsx
Normal file
48
apps/web/src/app/embed/waiting-for-turn.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
export const EmbedWaitingForTurn = () => {
|
||||
const [hasPostedMessage, setHasPostedMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.parent && !hasPostedMessage) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-waiting-for-turn',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasPostedMessage(true);
|
||||
}, [hasPostedMessage]);
|
||||
|
||||
if (!hasPostedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-foreground text-center text-2xl font-bold">
|
||||
<Trans>Waiting for Your Turn</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mt-8 max-w-[50ch] text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
It's currently not your turn to sign. Please check back soon as this document should be
|
||||
available for you to sign shortly.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
<Trans>Please check with the parent application for more information.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -85,7 +85,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
|
||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||
trpcReact.document.searchDocuments.useQuery(
|
||||
{
|
||||
query: search,
|
||||
|
||||
@ -67,7 +67,7 @@ export const TransferTeamDialog = ({
|
||||
const {
|
||||
data,
|
||||
refetch: refetchTeamMembers,
|
||||
isLoading: loadingTeamMembers,
|
||||
isPending: loadingTeamMembers,
|
||||
isLoadingError: loadingTeamMembersError,
|
||||
} = trpc.team.getTeamMembers.useQuery({
|
||||
teamId,
|
||||
|
||||
@ -353,6 +353,16 @@ export const DocumentHistorySheet = ({
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => (
|
||||
<DocumentHistorySheetChanges
|
||||
values={[
|
||||
{
|
||||
key: 'Field prefilled',
|
||||
value: formatGenericText(data.field.type),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
.exhaustive()}
|
||||
|
||||
{isUserDetailsVisible && (
|
||||
|
||||
275
apps/web/src/components/templates/template-bulk-send-dialog.tsx
Normal file
275
apps/web/src/components/templates/template-bulk-send-dialog.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { File as FileIcon, Upload, X } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZBulkSendFormSchema = z.object({
|
||||
file: z.instanceof(File),
|
||||
sendImmediately: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
|
||||
|
||||
export type TemplateBulkSendDialogProps = {
|
||||
templateId: number;
|
||||
recipients: Array<{ email: string; name?: string | null }>;
|
||||
trigger?: React.ReactNode;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const TemplateBulkSendDialog = ({
|
||||
templateId,
|
||||
recipients,
|
||||
trigger,
|
||||
onSuccess,
|
||||
}: TemplateBulkSendDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const form = useForm<TBulkSendFormSchema>({
|
||||
resolver: zodResolver(ZBulkSendFormSchema),
|
||||
defaultValues: {
|
||||
sendImmediately: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
|
||||
|
||||
const onDownloadTemplate = () => {
|
||||
const headers = recipients.flatMap((_, index) => [
|
||||
`recipient_${index + 1}_email`,
|
||||
`recipient_${index + 1}_name`,
|
||||
]);
|
||||
|
||||
const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']);
|
||||
|
||||
const csv = [headers.join(','), exampleRow.join(',')].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const a = Object.assign(document.createElement('a'), {
|
||||
href: url,
|
||||
download: 'template.csv',
|
||||
});
|
||||
|
||||
a.click();
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: TBulkSendFormSchema) => {
|
||||
try {
|
||||
const csv = await values.file.text();
|
||||
|
||||
await uploadBulkSend({
|
||||
templateId,
|
||||
teamId: team?.id,
|
||||
csv: csv,
|
||||
sendImmediately: values.sendImmediately,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(
|
||||
msg`Your bulk send has been initiated. You will receive an email notification upon completion.`,
|
||||
),
|
||||
});
|
||||
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to upload CSV. Please check the file format and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Bulk Send via CSV</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Bulk Send Template via CSV</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Upload a CSV file to create multiple documents from this template. Each row represents
|
||||
one document with its recipient details.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||
<div className="bg-muted/70 rounded-lg border p-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
<Trans>CSV Structure</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>
|
||||
For each recipient, provide their email (required) and name (optional) in separate
|
||||
columns. Download the template CSV below for the correct format.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-sm">
|
||||
<Trans>Current recipients:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
|
||||
{recipients.map((recipient, index) => (
|
||||
<li key={index}>
|
||||
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Button onClick={onDownloadTemplate} variant="outline" type="button">
|
||||
<Trans>Download Template CSV</Trans>
|
||||
</Button>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Pre-formatted CSV template with example data.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
{!value ? (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<label className="cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Upload CSV</Trans>
|
||||
</label>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex h-10 items-center rounded-md border px-3">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<FileIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span className="flex-1 truncate text-sm">{value.name}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="text-destructive hover:text-destructive p-0 text-xs"
|
||||
onClick={() => onChange(null)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
<Trans>Remove</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{error && <p className="text-destructive text-sm">{error.message}</p>}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
|
||||
template defaults.
|
||||
</Trans>
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendImmediately"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="send-immediately"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="send-immediately"
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
>
|
||||
<Trans>Send documents to recipients immediately</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="secondary" onClick={() => form.reset()} type="button">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Upload and Process</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.9.0-rc.11",
|
||||
"version": "1.9.1-rc.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.9.0-rc.11",
|
||||
"version": "1.9.1-rc.8",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@ -106,7 +106,7 @@
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@documenso/web",
|
||||
"version": "1.9.0-rc.11",
|
||||
"version": "1.9.1-rc.8",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
@ -13836,6 +13836,12 @@
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
|
||||
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
|
||||
},
|
||||
"node_modules/csv-parse": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
|
||||
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cytoscape": {
|
||||
"version": "3.28.1",
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.28.1.tgz",
|
||||
@ -35031,6 +35037,7 @@
|
||||
"@trigger.dev/sdk": "^2.3.18",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"@vvo/tzdb": "^6.117.0",
|
||||
"csv-parse": "^5.6.0",
|
||||
"inngest": "^3.19.13",
|
||||
"kysely": "^0.26.3",
|
||||
"luxon": "^3.4.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.9.0-rc.11",
|
||||
"version": "1.9.1-rc.8",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"build:web": "turbo run build --filter=@documenso/web",
|
||||
|
||||
@ -586,6 +586,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
recipients: body.recipients,
|
||||
prefillFields: body.prefillFields,
|
||||
override: {
|
||||
title: body.title,
|
||||
...body.meta,
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentDistributionMethod,
|
||||
@ -299,6 +299,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
|
||||
});
|
||||
|
||||
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
|
||||
|
||||
612
packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts
Normal file
612
packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts
Normal file
@ -0,0 +1,612 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
test.describe('Template Field Prefill API v1', () => {
|
||||
test('should create a document from template with prefilled fields', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// 1. Create a user
|
||||
const user = await seedUser();
|
||||
|
||||
// 2. Create an API token for the user
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
tokenName: 'test-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// 3. Create a template with seedBlankTemplate
|
||||
const template = await seedBlankTemplate(user, {
|
||||
createTemplateOptions: {
|
||||
title: 'Template with Advanced Fields',
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
token: 'test-token',
|
||||
readStatus: 'NOT_OPENED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Add fields to the template
|
||||
// Add TEXT field
|
||||
const textField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.TEXT,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 5,
|
||||
width: 10,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
label: 'Text Field',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add NUMBER field
|
||||
const numberField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.NUMBER,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 15,
|
||||
width: 10,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'number',
|
||||
label: 'Number Field',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add RADIO field
|
||||
const radioField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.RADIO,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 25,
|
||||
width: 10,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'radio',
|
||||
label: 'Radio Field',
|
||||
values: [
|
||||
{ id: 1, value: 'Option A', checked: false },
|
||||
{ id: 2, value: 'Option B', checked: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add CHECKBOX field
|
||||
const checkboxField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.CHECKBOX,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 35,
|
||||
width: 10,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'checkbox',
|
||||
label: 'Checkbox Field',
|
||||
values: [
|
||||
{ id: 1, value: 'Check A', checked: false },
|
||||
{ id: 2, value: 'Check B', checked: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add DROPDOWN field
|
||||
const dropdownField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.DROPDOWN,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 45,
|
||||
width: 10,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'dropdown',
|
||||
label: 'Dropdown Field',
|
||||
values: [{ value: 'Select A' }, { value: 'Select B' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 6. Sign in as the user
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
||||
|
||||
// 8. Create a document from the template with prefilled fields
|
||||
const response = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
title: 'Document with Prefilled Fields',
|
||||
recipients: [
|
||||
{
|
||||
id: recipient.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: 'SIGNER',
|
||||
},
|
||||
],
|
||||
prefillFields: [
|
||||
{
|
||||
id: textField.id,
|
||||
type: 'text',
|
||||
label: 'Prefilled Text',
|
||||
value: 'This is prefilled text',
|
||||
},
|
||||
{
|
||||
id: numberField.id,
|
||||
type: 'number',
|
||||
label: 'Prefilled Number',
|
||||
value: '42',
|
||||
},
|
||||
{
|
||||
id: radioField.id,
|
||||
type: 'radio',
|
||||
label: 'Prefilled Radio',
|
||||
value: 'Option A',
|
||||
},
|
||||
{
|
||||
id: checkboxField.id,
|
||||
type: 'checkbox',
|
||||
label: 'Prefilled Checkbox',
|
||||
value: ['Check A', 'Check B'],
|
||||
},
|
||||
{
|
||||
id: dropdownField.id,
|
||||
type: 'dropdown',
|
||||
label: 'Prefilled Dropdown',
|
||||
value: 'Select B',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
const responseData = await response.json();
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
expect(responseData.documentId).toBeDefined();
|
||||
|
||||
// 9. Verify the document was created with prefilled fields
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: responseData.documentId,
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(document).not.toBeNull();
|
||||
|
||||
// 10. Verify each field has the correct prefilled values
|
||||
const documentTextField = document?.fields.find(
|
||||
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
|
||||
);
|
||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
||||
type: 'text',
|
||||
label: 'Prefilled Text',
|
||||
text: 'This is prefilled text',
|
||||
});
|
||||
|
||||
const documentNumberField = document?.fields.find(
|
||||
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
|
||||
);
|
||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
||||
type: 'number',
|
||||
label: 'Prefilled Number',
|
||||
value: '42',
|
||||
});
|
||||
|
||||
const documentRadioField = document?.fields.find(
|
||||
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
|
||||
);
|
||||
expect(documentRadioField?.fieldMeta).toMatchObject({
|
||||
type: 'radio',
|
||||
label: 'Prefilled Radio',
|
||||
});
|
||||
// Check that the correct radio option is selected
|
||||
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
|
||||
const selectedRadioOption = radioValues.find((option) => option.checked);
|
||||
expect(selectedRadioOption?.value).toBe('Option A');
|
||||
|
||||
const documentCheckboxField = document?.fields.find(
|
||||
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
|
||||
);
|
||||
expect(documentCheckboxField?.fieldMeta).toMatchObject({
|
||||
type: 'checkbox',
|
||||
label: 'Prefilled Checkbox',
|
||||
});
|
||||
// Check that the correct checkbox options are selected
|
||||
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
|
||||
const checkedOptions = checkboxValues.filter((option) => option.checked);
|
||||
expect(checkedOptions.length).toBe(2);
|
||||
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
|
||||
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
|
||||
|
||||
const documentDropdownField = document?.fields.find(
|
||||
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
|
||||
);
|
||||
expect(documentDropdownField?.fieldMeta).toMatchObject({
|
||||
type: 'dropdown',
|
||||
label: 'Prefilled Dropdown',
|
||||
defaultValue: 'Select B',
|
||||
});
|
||||
|
||||
// 11. Sign in as the recipient and verify the prefilled fields are visible
|
||||
const documentRecipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId: document?.id,
|
||||
email: 'recipient@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
// Send the document to the recipient
|
||||
const sendResponse = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
sendEmail: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(sendResponse.ok()).toBeTruthy();
|
||||
expect(sendResponse.status()).toBe(200);
|
||||
|
||||
expect(documentRecipient).not.toBeNull();
|
||||
|
||||
// Visit the signing page
|
||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
||||
|
||||
// Verify the prefilled fields are visible with correct values
|
||||
// Text field
|
||||
await expect(page.getByText('This is prefilled')).toBeVisible();
|
||||
|
||||
// Number field
|
||||
await expect(page.getByText('42')).toBeVisible();
|
||||
|
||||
// Radio field
|
||||
await expect(page.getByText('Option A')).toBeVisible();
|
||||
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
|
||||
|
||||
// Checkbox field
|
||||
await expect(page.getByText('Check A')).toBeVisible();
|
||||
await expect(page.getByText('Check B')).toBeVisible();
|
||||
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
|
||||
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
|
||||
|
||||
// Dropdown field
|
||||
await expect(page.getByText('Select B')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a document from template without prefilled fields', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// 1. Create a user
|
||||
const user = await seedUser();
|
||||
|
||||
// 2. Create an API token for the user
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
tokenName: 'test-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// 3. Create a template with seedBlankTemplate
|
||||
const template = await seedBlankTemplate(user, {
|
||||
createTemplateOptions: {
|
||||
title: 'Template with Default Fields',
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
token: 'test-token',
|
||||
readStatus: 'NOT_OPENED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Add fields to the template
|
||||
// Add TEXT field
|
||||
const textField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.TEXT,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 5,
|
||||
width: 10,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
label: 'Default Text Field',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add NUMBER field
|
||||
const numberField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.NUMBER,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 15,
|
||||
width: 10,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'number',
|
||||
label: 'Default Number Field',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 6. Sign in as the user
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
||||
|
||||
// 8. Create a document from the template without prefilled fields
|
||||
const response = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
title: 'Document with Default Fields',
|
||||
recipients: [
|
||||
{
|
||||
id: recipient.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: 'SIGNER',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
expect(responseData.documentId).toBeDefined();
|
||||
|
||||
// 9. Verify the document was created with default fields
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: responseData.documentId,
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(document).not.toBeNull();
|
||||
|
||||
// 10. Verify fields have their default values
|
||||
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
|
||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
||||
type: 'text',
|
||||
label: 'Default Text Field',
|
||||
});
|
||||
|
||||
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
|
||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
||||
type: 'number',
|
||||
label: 'Default Number Field',
|
||||
});
|
||||
|
||||
// 11. Sign in as the recipient and verify the default fields are visible
|
||||
const documentRecipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId: document?.id,
|
||||
email: 'recipient@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
expect(documentRecipient).not.toBeNull();
|
||||
|
||||
const sendResponse = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
sendEmail: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(sendResponse.ok()).toBeTruthy();
|
||||
expect(sendResponse.status()).toBe(200);
|
||||
|
||||
// Visit the signing page
|
||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
||||
|
||||
// Verify the default fields are visible with correct labels
|
||||
await expect(page.getByText('Default Text Field')).toBeVisible();
|
||||
await expect(page.getByText('Default Number Field')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle invalid field prefill values', async ({ request }) => {
|
||||
// 1. Create a user
|
||||
const user = await seedUser();
|
||||
|
||||
// 2. Create an API token for the user
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
tokenName: 'test-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// 3. Create a template using seedBlankTemplate
|
||||
const template = await seedBlankTemplate(user, {
|
||||
createTemplateOptions: {
|
||||
title: 'Template for Invalid Test',
|
||||
visibility: 'EVERYONE',
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
token: 'test-token',
|
||||
readStatus: 'NOT_OPENED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Add a field to the template
|
||||
const field = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.RADIO,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 100,
|
||||
width: 100,
|
||||
height: 50,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'radio',
|
||||
label: 'Radio Field',
|
||||
values: [
|
||||
{ id: 1, value: 'Option A', checked: false },
|
||||
{ id: 2, value: 'Option B', checked: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 6. Try to create a document with invalid prefill value
|
||||
const response = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
title: 'Document with Invalid Prefill',
|
||||
recipients: [
|
||||
{
|
||||
id: recipient.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: 'SIGNER',
|
||||
},
|
||||
],
|
||||
prefillFields: [
|
||||
{
|
||||
id: field.id,
|
||||
type: 'radio',
|
||||
label: 'Invalid Radio',
|
||||
value: 'Non-existent Option', // This option doesn't exist
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 7. Verify the request fails with appropriate error
|
||||
expect(response.ok()).toBeFalsy();
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const errorData = await response.json();
|
||||
expect(errorData.message).toContain('not found in options for RADIO field');
|
||||
});
|
||||
});
|
||||
600
packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts
Normal file
600
packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts
Normal file
@ -0,0 +1,600 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
test.describe('Template Field Prefill API v2', () => {
|
||||
test('should create a document from template with prefilled fields', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// 1. Create a user
|
||||
const user = await seedUser();
|
||||
|
||||
// 2. Create an API token for the user
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
tokenName: 'test-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// 3. Create a template with seedBlankTemplate
|
||||
const template = await seedBlankTemplate(user, {
|
||||
createTemplateOptions: {
|
||||
title: 'Template with Advanced Fields V2',
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
token: 'test-token',
|
||||
readStatus: 'NOT_OPENED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Add fields to the template
|
||||
// Add TEXT field
|
||||
const textField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.TEXT,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 5,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
label: 'Text Field',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add NUMBER field
|
||||
const numberField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.NUMBER,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 15,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'number',
|
||||
label: 'Number Field',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add RADIO field
|
||||
const radioField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.RADIO,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 25,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'radio',
|
||||
label: 'Radio Field',
|
||||
values: [
|
||||
{ id: 1, value: 'Option A', checked: false },
|
||||
{ id: 2, value: 'Option B', checked: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add CHECKBOX field
|
||||
const checkboxField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.CHECKBOX,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 35,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'checkbox',
|
||||
label: 'Checkbox Field',
|
||||
values: [
|
||||
{ id: 1, value: 'Check A', checked: false },
|
||||
{ id: 2, value: 'Check B', checked: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add DROPDOWN field
|
||||
const dropdownField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.DROPDOWN,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 45,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'dropdown',
|
||||
label: 'Dropdown Field',
|
||||
values: [{ value: 'Select A' }, { value: 'Select B' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 6. Sign in as the user
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
||||
|
||||
// 8. Create a document from the template with prefilled fields using v2 API
|
||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipients: [
|
||||
{
|
||||
id: recipient.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
},
|
||||
],
|
||||
prefillFields: [
|
||||
{
|
||||
id: textField.id,
|
||||
type: 'text',
|
||||
label: 'Prefilled Text',
|
||||
value: 'This is prefilled text',
|
||||
},
|
||||
{
|
||||
id: numberField.id,
|
||||
type: 'number',
|
||||
label: 'Prefilled Number',
|
||||
value: '42',
|
||||
},
|
||||
{
|
||||
id: radioField.id,
|
||||
type: 'radio',
|
||||
label: 'Prefilled Radio',
|
||||
value: 'Option A',
|
||||
},
|
||||
{
|
||||
id: checkboxField.id,
|
||||
type: 'checkbox',
|
||||
label: 'Prefilled Checkbox',
|
||||
value: ['Check A', 'Check B'],
|
||||
},
|
||||
{
|
||||
id: dropdownField.id,
|
||||
type: 'dropdown',
|
||||
label: 'Prefilled Dropdown',
|
||||
value: 'Select B',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
expect(responseData.id).toBeDefined();
|
||||
|
||||
// 9. Verify the document was created with prefilled fields
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: responseData.id,
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(document).not.toBeNull();
|
||||
|
||||
// 10. Verify each field has the correct prefilled values
|
||||
const documentTextField = document?.fields.find(
|
||||
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
|
||||
);
|
||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
||||
type: 'text',
|
||||
label: 'Prefilled Text',
|
||||
text: 'This is prefilled text',
|
||||
});
|
||||
|
||||
const documentNumberField = document?.fields.find(
|
||||
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
|
||||
);
|
||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
||||
type: 'number',
|
||||
label: 'Prefilled Number',
|
||||
value: '42',
|
||||
});
|
||||
|
||||
const documentRadioField = document?.fields.find(
|
||||
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
|
||||
);
|
||||
expect(documentRadioField?.fieldMeta).toMatchObject({
|
||||
type: 'radio',
|
||||
label: 'Prefilled Radio',
|
||||
});
|
||||
// Check that the correct radio option is selected
|
||||
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
|
||||
const selectedRadioOption = radioValues.find((option) => option.checked);
|
||||
expect(selectedRadioOption?.value).toBe('Option A');
|
||||
|
||||
const documentCheckboxField = document?.fields.find(
|
||||
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
|
||||
);
|
||||
expect(documentCheckboxField?.fieldMeta).toMatchObject({
|
||||
type: 'checkbox',
|
||||
label: 'Prefilled Checkbox',
|
||||
});
|
||||
// Check that the correct checkbox options are selected
|
||||
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
|
||||
const checkedOptions = checkboxValues.filter((option) => option.checked);
|
||||
expect(checkedOptions.length).toBe(2);
|
||||
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
|
||||
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
|
||||
|
||||
const documentDropdownField = document?.fields.find(
|
||||
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
|
||||
);
|
||||
expect(documentDropdownField?.fieldMeta).toMatchObject({
|
||||
type: 'dropdown',
|
||||
label: 'Prefilled Dropdown',
|
||||
defaultValue: 'Select B',
|
||||
});
|
||||
|
||||
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
documentId: document?.id,
|
||||
meta: {
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(sendResponse.ok()).toBeTruthy();
|
||||
await expect(sendResponse.status()).toBe(200);
|
||||
|
||||
// 11. Sign in as the recipient and verify the prefilled fields are visible
|
||||
const documentRecipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId: document?.id,
|
||||
email: 'recipient@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
expect(documentRecipient).not.toBeNull();
|
||||
|
||||
// Visit the signing page
|
||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
||||
|
||||
// Verify the prefilled fields are visible with correct values
|
||||
// Text field
|
||||
await expect(page.getByText('This is prefilled')).toBeVisible();
|
||||
|
||||
// Number field
|
||||
await expect(page.getByText('42')).toBeVisible();
|
||||
|
||||
// Radio field
|
||||
await expect(page.getByText('Option A')).toBeVisible();
|
||||
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
|
||||
|
||||
// Checkbox field
|
||||
await expect(page.getByText('Check A')).toBeVisible();
|
||||
await expect(page.getByText('Check B')).toBeVisible();
|
||||
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
|
||||
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
|
||||
|
||||
// Dropdown field
|
||||
await expect(page.getByText('Select B')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a document from template without prefilled fields', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// 1. Create a user
|
||||
const user = await seedUser();
|
||||
|
||||
// 2. Create an API token for the user
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
tokenName: 'test-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// 3. Create a template with seedBlankTemplate
|
||||
const template = await seedBlankTemplate(user, {
|
||||
createTemplateOptions: {
|
||||
title: 'Template with Default Fields V2',
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
token: 'test-token',
|
||||
readStatus: 'NOT_OPENED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Add fields to the template
|
||||
// Add TEXT field
|
||||
const textField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.TEXT,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 5,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'text',
|
||||
label: 'Default Text Field',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add NUMBER field
|
||||
const numberField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.NUMBER,
|
||||
page: 1,
|
||||
positionX: 5,
|
||||
positionY: 15,
|
||||
width: 20,
|
||||
height: 5,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'number',
|
||||
label: 'Default Number Field',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 6. Sign in as the user
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
||||
|
||||
// 8. Create a document from the template without prefilled fields using v2 API
|
||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipients: [
|
||||
{
|
||||
id: recipient.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
expect(responseData.id).toBeDefined();
|
||||
|
||||
// 9. Verify the document was created with default fields
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: responseData.id,
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(document).not.toBeNull();
|
||||
|
||||
// 10. Verify fields have their default values
|
||||
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
|
||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
||||
type: 'text',
|
||||
label: 'Default Text Field',
|
||||
});
|
||||
|
||||
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
|
||||
expect(documentNumberField?.fieldMeta).toMatchObject({
|
||||
type: 'number',
|
||||
label: 'Default Number Field',
|
||||
});
|
||||
|
||||
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
documentId: document?.id,
|
||||
meta: {
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(sendResponse.ok()).toBeTruthy();
|
||||
await expect(sendResponse.status()).toBe(200);
|
||||
|
||||
// 11. Sign in as the recipient and verify the default fields are visible
|
||||
const documentRecipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId: document?.id,
|
||||
email: 'recipient@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
expect(documentRecipient).not.toBeNull();
|
||||
|
||||
// Visit the signing page
|
||||
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
|
||||
|
||||
await expect(page.getByText('This is prefilled')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle invalid field prefill values', async ({ request }) => {
|
||||
// 1. Create a user
|
||||
const user = await seedUser();
|
||||
|
||||
// 2. Create an API token for the user
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
tokenName: 'test-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// 3. Create a template using seedBlankTemplate
|
||||
const template = await seedBlankTemplate(user, {
|
||||
createTemplateOptions: {
|
||||
title: 'Template for Invalid Test V2',
|
||||
visibility: 'EVERYONE',
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
token: 'test-token',
|
||||
readStatus: 'NOT_OPENED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Add a field to the template
|
||||
const field = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.RADIO,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 100,
|
||||
width: 100,
|
||||
height: 50,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: {
|
||||
type: 'radio',
|
||||
label: 'Radio Field',
|
||||
values: [
|
||||
{ id: 1, value: 'Option A', checked: false },
|
||||
{ id: 2, value: 'Option B', checked: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 7. Try to create a document with invalid prefill value
|
||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
templateId: template.id,
|
||||
recipients: [
|
||||
{
|
||||
id: recipient.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
},
|
||||
],
|
||||
prefillFields: [
|
||||
{
|
||||
id: field.id,
|
||||
type: 'radio',
|
||||
label: 'Invalid Radio',
|
||||
value: 'Non-existent Option', // This option doesn't exist
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// 8. Verify the request fails with appropriate error
|
||||
expect(response.ok()).toBeFalsy();
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const errorData = await response.json();
|
||||
expect(errorData.message).toContain('not found in options for RADIO field');
|
||||
});
|
||||
});
|
||||
@ -384,7 +384,9 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page
|
||||
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Complete' : 'Approve' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
|
||||
.click();
|
||||
@ -454,7 +456,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
|
||||
const { status } = await getDocumentByToken(token);
|
||||
expect(status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
|
||||
@ -540,12 +542,19 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
if (i > 1) {
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
}
|
||||
|
||||
await page
|
||||
.getByPlaceholder('Email')
|
||||
.getByLabel('Email')
|
||||
.nth(i - 1)
|
||||
.focus();
|
||||
|
||||
await page
|
||||
.getByLabel('Email')
|
||||
.nth(i - 1)
|
||||
.fill(`user${i}@example.com`);
|
||||
|
||||
await page
|
||||
.getByPlaceholder('Name')
|
||||
.getByLabel('Name')
|
||||
.nth(i - 1)
|
||||
.fill(`User ${i}`);
|
||||
}
|
||||
|
||||
@ -4,15 +4,14 @@ import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
|
||||
|
||||
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
|
||||
const planTypes = typeof plan === 'string' ? [plan] : plan;
|
||||
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
|
||||
|
||||
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
|
||||
|
||||
const { data: prices } = await stripe.prices.search({
|
||||
query,
|
||||
const prices = await stripe.prices.list({
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return prices.filter((price) => price.type === 'recurring');
|
||||
return prices.data.filter(
|
||||
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
|
||||
);
|
||||
};
|
||||
|
||||
56
packages/ee/server-only/util/is-community-plan.ts
Normal file
56
packages/ee/server-only/util/is-community-plan.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Subscription } from '@documenso/prisma/client';
|
||||
|
||||
import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices';
|
||||
|
||||
export type IsCommunityPlanOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the user or team is on the community plan.
|
||||
*/
|
||||
export const isCommunityPlan = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: IsCommunityPlanOptions): Promise<boolean> => {
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
if (teamId) {
|
||||
subscriptions = await prisma.team
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
select: {
|
||||
owner: {
|
||||
include: {
|
||||
subscriptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((team) => team.owner.subscriptions);
|
||||
} else {
|
||||
subscriptions = await prisma.user
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
subscriptions: true,
|
||||
},
|
||||
})
|
||||
.then((user) => user.subscriptions);
|
||||
}
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const communityPlanPriceIds = await getCommunityPlanPriceIds();
|
||||
|
||||
return subscriptionsContainsActivePlan(subscriptions, communityPlanPriceIds);
|
||||
};
|
||||
@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { match } from 'ts-pattern';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
@ -40,11 +40,9 @@ export const TemplateDocumentInvite = ({
|
||||
|
||||
const rejectDocumentLink = useMemo(() => {
|
||||
const url = new URL(signDocumentLink);
|
||||
|
||||
url.searchParams.set('reject', 'true');
|
||||
|
||||
return url.toString();
|
||||
}, []);
|
||||
}, [signDocumentLink]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -52,31 +50,32 @@ export const TemplateDocumentInvite = ({
|
||||
|
||||
<Section>
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{selfSigner ? (
|
||||
<Trans>
|
||||
Please {_(actionVerb).toLowerCase()} your document
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
) : isTeamInvite ? (
|
||||
<>
|
||||
{includeSenderDetails ? (
|
||||
<Trans>
|
||||
{inviterName} on behalf of "{teamName}" has invited you to{' '}
|
||||
{_(actionVerb).toLowerCase()}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{teamName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
</Trans>
|
||||
)}
|
||||
<br />"{documentName}"
|
||||
</>
|
||||
) : (
|
||||
<Trans>
|
||||
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
)}
|
||||
{match({ selfSigner, isTeamInvite, includeSenderDetails, teamName })
|
||||
.with({ selfSigner: true }, () => (
|
||||
<Trans>
|
||||
Please {_(actionVerb).toLowerCase()} your document
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
))
|
||||
.with({ isTeamInvite: true, includeSenderDetails: true, teamName: P.string }, () => (
|
||||
<Trans>
|
||||
{inviterName} on behalf of "{teamName}" has invited you to{' '}
|
||||
{_(actionVerb).toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
))
|
||||
.with({ isTeamInvite: true, teamName: P.string }, () => (
|
||||
<Trans>
|
||||
{teamName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Trans>
|
||||
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
))}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
@ -85,6 +84,9 @@ export const TemplateDocumentInvite = ({
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<Trans>Continue by assisting with the document.</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Text>
|
||||
|
||||
@ -105,6 +107,7 @@ export const TemplateDocumentInvite = ({
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.CC, () => '')
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
91
packages/email/templates/bulk-send-complete.tsx
Normal file
91
packages/email/templates/bulk-send-complete.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Preview, Section, Text } from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export interface BulkSendCompleteEmailProps {
|
||||
userName: string;
|
||||
templateName: string;
|
||||
totalProcessed: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
errors: string[];
|
||||
assetBaseUrl?: string;
|
||||
}
|
||||
|
||||
export const BulkSendCompleteEmail = ({
|
||||
userName,
|
||||
templateName,
|
||||
totalProcessed,
|
||||
successCount,
|
||||
failedCount,
|
||||
errors,
|
||||
}: BulkSendCompleteEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Text className="text-sm">
|
||||
<Trans>Hi {userName},</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm">
|
||||
<Trans>Your bulk send operation for template "{templateName}" has completed.</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="text-lg font-semibold">
|
||||
<Trans>Summary:</Trans>
|
||||
</Text>
|
||||
|
||||
<ul className="my-2 ml-4 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>Total rows processed: {totalProcessed}</Trans>
|
||||
</li>
|
||||
<li className="mt-1">
|
||||
<Trans>Successfully created: {successCount}</Trans>
|
||||
</li>
|
||||
<li className="mt-1">
|
||||
<Trans>Failed: {failedCount}</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{failedCount > 0 && (
|
||||
<Section className="mt-4">
|
||||
<Text className="text-lg font-semibold">
|
||||
<Trans>The following errors occurred:</Trans>
|
||||
</Text>
|
||||
|
||||
<ul className="my-2 ml-4 list-inside list-disc">
|
||||
{errors.map((error, index) => (
|
||||
<li key={index} className="text-destructive mt-1 text-sm text-slate-400">
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Text className="text-sm">
|
||||
<Trans>
|
||||
You can view the created documents in your dashboard under the "Documents created
|
||||
from template" section.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
@ -10,6 +10,9 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
|
||||
[DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: {
|
||||
description: 'Approval request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.ASSISTING_REQUEST]: {
|
||||
description: 'Assisting request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.CC]: {
|
||||
description: 'CC',
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es'] as const;
|
||||
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es', 'it', 'pl'] as const;
|
||||
|
||||
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
|
||||
|
||||
@ -46,6 +46,14 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
|
||||
full: 'Spanish',
|
||||
short: 'es',
|
||||
},
|
||||
it: {
|
||||
full: 'Italian',
|
||||
short: 'it',
|
||||
},
|
||||
pl: {
|
||||
short: 'pl',
|
||||
full: 'Polish',
|
||||
},
|
||||
} satisfies Record<SupportedLanguageCodes, SupportedLanguage>;
|
||||
|
||||
export const isValidLanguageCode = (code: unknown): code is SupportedLanguageCodes =>
|
||||
|
||||
@ -32,12 +32,26 @@ export const RECIPIENT_ROLES_DESCRIPTION = {
|
||||
roleName: msg`Viewer`,
|
||||
roleNamePlural: msg`Viewers`,
|
||||
},
|
||||
[RecipientRole.ASSISTANT]: {
|
||||
actionVerb: msg`Assist`,
|
||||
actioned: msg`Assisted`,
|
||||
progressiveVerb: msg`Assisting`,
|
||||
roleName: msg`Assistant`,
|
||||
roleNamePlural: msg`Assistants`,
|
||||
},
|
||||
} satisfies Record<keyof typeof RecipientRole, unknown>;
|
||||
|
||||
export const RECIPIENT_ROLE_TO_DISPLAY_TYPE = {
|
||||
[RecipientRole.SIGNER]: `SIGNING_REQUEST`,
|
||||
[RecipientRole.VIEWER]: `VIEW_REQUEST`,
|
||||
[RecipientRole.APPROVER]: `APPROVE_REQUEST`,
|
||||
} as const;
|
||||
|
||||
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
||||
[RecipientRole.SIGNER]: `SIGNING_REQUEST`,
|
||||
[RecipientRole.VIEWER]: `VIEW_REQUEST`,
|
||||
[RecipientRole.APPROVER]: `APPROVE_REQUEST`,
|
||||
[RecipientRole.ASSISTANT]: `ASSISTING_REQUEST`,
|
||||
} as const;
|
||||
|
||||
export const RECIPIENT_ROLE_SIGNING_REASONS = {
|
||||
@ -45,4 +59,5 @@ export const RECIPIENT_ROLE_SIGNING_REASONS = {
|
||||
[RecipientRole.APPROVER]: msg`I am an approver of this document`,
|
||||
[RecipientRole.CC]: msg`I am required to receive a copy of this document`,
|
||||
[RecipientRole.VIEWER]: msg`I am a viewer of this document`,
|
||||
[RecipientRole.ASSISTANT]: msg`I am an assistant of this document`,
|
||||
} satisfies Record<keyof typeof RecipientRole, MessageDescriptor>;
|
||||
|
||||
@ -6,6 +6,7 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-sig
|
||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
|
||||
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
|
||||
|
||||
/**
|
||||
@ -21,6 +22,7 @@ export const jobsClient = new JobClient([
|
||||
SEAL_DOCUMENT_JOB_DEFINITION,
|
||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||
] as const);
|
||||
|
||||
export const jobs = jobsClient;
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID = 'send.bulk.complete.email';
|
||||
|
||||
const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
userId: z.number(),
|
||||
templateId: z.number(),
|
||||
templateName: z.string(),
|
||||
totalProcessed: z.number(),
|
||||
successCount: z.number(),
|
||||
failedCount: z.number(),
|
||||
errors: z.array(z.string()),
|
||||
requestMetadata: ZRequestMetadataSchema.optional(),
|
||||
});
|
||||
|
||||
export type TSendBulkCompleteEmailJobDefinition = z.infer<
|
||||
typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Bulk Complete Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-bulk-complete-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendBulkCompleteEmailJobDefinition
|
||||
>;
|
||||
@ -0,0 +1,208 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-complete';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
|
||||
import { AppError } from '../../../errors/app-error';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
|
||||
|
||||
const ZRecipientRowSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.union([
|
||||
z.string().email({ message: 'Value must be a valid email or empty string' }),
|
||||
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
|
||||
]),
|
||||
});
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TBulkSendTemplateJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const rows = parse(csvContent, { columns: true, skip_empty_lines: true });
|
||||
|
||||
if (rows.length > 100) {
|
||||
throw new Error('Maximum 100 rows allowed per upload');
|
||||
}
|
||||
|
||||
const { recipients } = template;
|
||||
|
||||
// Validate CSV structure
|
||||
const csvHeaders = Object.keys(rows[0]);
|
||||
const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`);
|
||||
|
||||
for (const header of requiredHeaders) {
|
||||
if (!csvHeaders.includes(header)) {
|
||||
throw new Error(`Missing required column: ${header}`);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const results = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: Array<string>(),
|
||||
};
|
||||
|
||||
// Process each row
|
||||
for (const [rowIndex, row] of rows.entries()) {
|
||||
try {
|
||||
for (const [recipientIndex] of recipients.entries()) {
|
||||
const nameKey = `recipient_${recipientIndex + 1}_name`;
|
||||
const emailKey = `recipient_${recipientIndex + 1}_email`;
|
||||
|
||||
const parsed = ZRecipientRowSchema.safeParse({
|
||||
name: row[nameKey],
|
||||
email: row[emailKey],
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Invalid recipient data provided for ${emailKey}, ${nameKey}: ${parsed.error.issues?.[0]?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const document = await io.runTask(`create-document-${rowIndex}`, async () => {
|
||||
return await createDocumentFromTemplate({
|
||||
templateId: template.id,
|
||||
userId,
|
||||
teamId,
|
||||
recipients: recipients.map((recipient, index) => {
|
||||
return {
|
||||
id: recipient.id,
|
||||
email: row[`recipient_${index + 1}_email`] || recipient.email,
|
||||
name: row[`recipient_${index + 1}_name`] || recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
};
|
||||
}),
|
||||
requestMetadata: {
|
||||
source: 'app',
|
||||
auth: 'session',
|
||||
requestMetadata: requestMetadata || {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (sendImmediately) {
|
||||
await io.runTask(`send-document-${rowIndex}`, async () => {
|
||||
await sendDocument({
|
||||
documentId: document.id,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata: {
|
||||
source: 'app',
|
||||
auth: 'session',
|
||||
requestMetadata: requestMetadata || {},
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
throw new AppError('DOCUMENT_SEND_FAILED');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
results.success += 1;
|
||||
} catch (error) {
|
||||
results.failed += 1;
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
results.errors.push(`Row ${rowIndex + 1}: Was unable to be processed - ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
await io.runTask('send-completion-email', async () => {
|
||||
const completionTemplate = createElement(BulkSendCompleteEmail, {
|
||||
userName: user.name || user.email,
|
||||
templateName: template.title,
|
||||
totalProcessed: rows.length,
|
||||
successCount: results.success,
|
||||
failedCount: results.failed,
|
||||
errors: results.errors,
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
let teamGlobalSettings: TeamGlobalSettings | undefined | null;
|
||||
|
||||
if (template.teamId) {
|
||||
teamGlobalSettings = await prisma.teamGlobalSettings.findUnique({
|
||||
where: {
|
||||
teamId: template.teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const branding = teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const i18n = await getI18nInstance(teamGlobalSettings?.documentLanguage);
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(completionTemplate, {
|
||||
lang: teamGlobalSettings?.documentLanguage,
|
||||
branding,
|
||||
}),
|
||||
renderEmailWithI18N(completionTemplate, {
|
||||
lang: teamGlobalSettings?.documentLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: user.name || '',
|
||||
address: user.email,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`Bulk Send Complete: ${template.title}`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
};
|
||||
37
packages/lib/jobs/definitions/internal/bulk-send-template.ts
Normal file
37
packages/lib/jobs/definitions/internal/bulk-send-template.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const BULK_SEND_TEMPLATE_JOB_DEFINITION_ID = 'internal.bulk-send-template';
|
||||
|
||||
const BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA = z.object({
|
||||
userId: z.number(),
|
||||
teamId: z.number().optional(),
|
||||
templateId: z.number(),
|
||||
csvContent: z.string(),
|
||||
sendImmediately: z.boolean(),
|
||||
requestMetadata: ZRequestMetadataSchema.optional(),
|
||||
});
|
||||
|
||||
export type TBulkSendTemplateJobDefinition = z.infer<
|
||||
typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const BULK_SEND_TEMPLATE_JOB_DEFINITION = {
|
||||
id: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
|
||||
name: 'Bulk Send Template',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
|
||||
schema: BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./bulk-send-template.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
|
||||
TBulkSendTemplateJobDefinition
|
||||
>;
|
||||
@ -40,6 +40,7 @@
|
||||
"@trigger.dev/sdk": "^2.3.18",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"@vvo/tzdb": "^6.117.0",
|
||||
"csv-parse": "^5.6.0",
|
||||
"inngest": "^3.19.13",
|
||||
"kysely": "^0.26.3",
|
||||
"luxon": "^3.4.0",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, Prisma } from '@documenso/prisma/client';
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type SigningVolume = {
|
||||
id: number;
|
||||
@ -24,92 +24,78 @@ export async function getSigningVolume({
|
||||
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 offset = Math.max(page - 1, 0) * perPage;
|
||||
|
||||
const [subscriptions, totalCount] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
documents: {
|
||||
where: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
deletedAt: null,
|
||||
teamId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
documents: {
|
||||
where: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy:
|
||||
sortBy === 'name'
|
||||
? [{ user: { name: sortOrder } }, { team: { name: sortOrder } }, { createdAt: 'desc' }]
|
||||
: sortBy === 'createdAt'
|
||||
? [{ createdAt: sortOrder }]
|
||||
: undefined,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.subscription.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Subscription as s')
|
||||
.leftJoin('User as u', 's.userId', 'u.id')
|
||||
.leftJoin('Team as t', 's.teamId', 't.id')
|
||||
.leftJoin('Document as ud', (join) =>
|
||||
join
|
||||
.onRef('u.id', '=', 'ud.userId')
|
||||
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('ud.deletedAt', 'is', null)
|
||||
.on('ud.teamId', 'is', null),
|
||||
)
|
||||
.leftJoin('Document as td', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'td.teamId')
|
||||
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('td.deletedAt', 'is', null),
|
||||
)
|
||||
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
|
||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('u.name', 'ilike', `%${search}%`),
|
||||
eb('u.email', 'ilike', `%${search}%`),
|
||||
eb('t.name', 'ilike', `%${search}%`),
|
||||
]),
|
||||
)
|
||||
.select([
|
||||
's.id as id',
|
||||
's.createdAt as createdAt',
|
||||
's.planId as planId',
|
||||
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
|
||||
])
|
||||
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
|
||||
|
||||
const leaderboardWithVolume: SigningVolume[] = subscriptions.map((subscription) => {
|
||||
const name =
|
||||
subscription.user?.name || subscription.team?.name || subscription.user?.email || 'Unknown';
|
||||
const userSignedDocs = subscription.user?.documents?.length || 0;
|
||||
const teamSignedDocs = subscription.team?.documents?.length || 0;
|
||||
return {
|
||||
id: subscription.id,
|
||||
name,
|
||||
signingVolume: userSignedDocs + teamSignedDocs,
|
||||
createdAt: subscription.createdAt,
|
||||
planId: subscription.planId,
|
||||
};
|
||||
});
|
||||
|
||||
if (sortBy === 'signingVolume') {
|
||||
leaderboardWithVolume.sort((a, b) => {
|
||||
return sortOrder === 'desc'
|
||||
? b.signingVolume - a.signingVolume
|
||||
: a.signingVolume - b.signingVolume;
|
||||
});
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
findQuery = findQuery.orderBy('name', sortOrder);
|
||||
break;
|
||||
case 'createdAt':
|
||||
findQuery = findQuery.orderBy('createdAt', sortOrder);
|
||||
break;
|
||||
case 'signingVolume':
|
||||
findQuery = findQuery.orderBy('signingVolume', sortOrder);
|
||||
break;
|
||||
default:
|
||||
findQuery = findQuery.orderBy('signingVolume', 'desc');
|
||||
}
|
||||
|
||||
findQuery = findQuery.limit(perPage).offset(offset);
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Subscription as s')
|
||||
.leftJoin('User as u', 's.userId', 'u.id')
|
||||
.leftJoin('Team as t', 's.teamId', 't.id')
|
||||
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
|
||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('u.name', 'ilike', `%${search}%`),
|
||||
eb('u.email', 'ilike', `%${search}%`),
|
||||
eb('t.name', 'ilike', `%${search}%`),
|
||||
]),
|
||||
)
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
|
||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||
|
||||
return {
|
||||
leaderboard: leaderboardWithVolume,
|
||||
totalPages: Math.ceil(totalCount / perPage),
|
||||
leaderboard: results,
|
||||
totalPages: Math.ceil(Number(count) / perPage),
|
||||
};
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
WebhookTriggerEvents,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import {
|
||||
@ -72,6 +73,13 @@ export const completeDocumentWithToken = async ({
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Recipient has already rejected the document',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ import type {
|
||||
TeamGlobalSettings,
|
||||
User,
|
||||
} from '@documenso/prisma/client';
|
||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, SendStatus, WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
@ -23,10 +23,15 @@ import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type DeleteDocumentOptions = {
|
||||
id: number;
|
||||
@ -112,6 +117,13 @@ export const deleteDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
// Return partial document for API v1 response.
|
||||
return {
|
||||
id: document.id,
|
||||
|
||||
@ -88,6 +88,7 @@ export const findDocuments = async ({
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ externalId: { contains: query, mode: 'insensitive' } },
|
||||
{ recipients: { some: { name: { contains: query, mode: 'insensitive' } } } },
|
||||
{ recipients: { some: { email: { contains: query, mode: 'insensitive' } } } },
|
||||
],
|
||||
|
||||
@ -14,8 +14,8 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
@ -34,6 +34,14 @@ export const searchDocumentsWithKeyword = async ({
|
||||
userId: userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
externalId: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
userId: userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
@ -88,6 +96,23 @@ export const searchDocumentsWithKeyword = async ({
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
externalId: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
teamId: {
|
||||
not: null,
|
||||
},
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
|
||||
@ -1,15 +1,55 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type GetFieldsForTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||
return await prisma.field.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
type: {
|
||||
not: FieldType.SIGNATURE,
|
||||
},
|
||||
recipient: {
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
},
|
||||
documentId: recipient.documentId,
|
||||
},
|
||||
{
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.field.findMany({
|
||||
where: {
|
||||
recipient: {
|
||||
token,
|
||||
},
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
|
||||
@ -4,7 +4,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type RemovedSignedFieldWithTokenOptions = {
|
||||
token: string;
|
||||
@ -17,11 +17,28 @@ export const removeSignedFieldWithToken = async ({
|
||||
fieldId,
|
||||
requestMetadata,
|
||||
}: RemovedSignedFieldWithTokenOptions) => {
|
||||
const recipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
const field = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
id: fieldId,
|
||||
recipient: {
|
||||
token,
|
||||
...(recipient.role !== RecipientRole.ASSISTANT
|
||||
? {
|
||||
id: recipient.id,
|
||||
}
|
||||
: {
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
@ -30,7 +47,7 @@ export const removeSignedFieldWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const { document, recipient } = field;
|
||||
const { document } = field;
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`Document not found for field ${field.id}`);
|
||||
@ -40,7 +57,10 @@ export const removeSignedFieldWithToken = async ({
|
||||
throw new Error(`Document ${document.id} must be pending`);
|
||||
}
|
||||
|
||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||
if (
|
||||
recipient?.signingStatus === SigningStatus.SIGNED ||
|
||||
field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
@ -66,20 +86,22 @@ export const removeSignedFieldWithToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient?.name,
|
||||
email: recipient?.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
field: field.type,
|
||||
fieldId: field.secondaryId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (recipient.role !== RecipientRole.ASSISTANT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
field: field.type,
|
||||
fieldId: field.secondaryId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@ -10,7 +10,7 @@ import { validateRadioField } from '@documenso/lib/advanced-fields-validation/va
|
||||
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
|
||||
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
|
||||
@ -56,20 +56,41 @@ export const signFieldWithToken = async ({
|
||||
authOptions,
|
||||
requestMetadata,
|
||||
}: SignFieldWithTokenOptions) => {
|
||||
const recipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
const field = await prisma.field.findFirstOrThrow({
|
||||
where: {
|
||||
id: fieldId,
|
||||
recipient: {
|
||||
token,
|
||||
...(recipient.role !== RecipientRole.ASSISTANT
|
||||
? {
|
||||
id: recipient.id,
|
||||
}
|
||||
: {
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
signingOrder: {
|
||||
gte: recipient.signingOrder ?? 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
document: true,
|
||||
document: {
|
||||
include: {
|
||||
recipients: true,
|
||||
},
|
||||
},
|
||||
recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { document, recipient } = field;
|
||||
const { document } = field;
|
||||
|
||||
if (!document) {
|
||||
throw new Error(`Document not found for field ${field.id}`);
|
||||
@ -87,7 +108,10 @@ export const signFieldWithToken = async ({
|
||||
throw new Error(`Document ${document.id} must be pending for signing`);
|
||||
}
|
||||
|
||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||
if (
|
||||
recipient.signingStatus === SigningStatus.SIGNED ||
|
||||
field.recipient.signingStatus === SigningStatus.SIGNED
|
||||
) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
}
|
||||
|
||||
@ -183,6 +207,8 @@ export const signFieldWithToken = async ({
|
||||
throw new Error('Typed signatures are not allowed. Please draw your signature');
|
||||
}
|
||||
|
||||
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const updatedField = await tx.field.update({
|
||||
where: {
|
||||
@ -219,11 +245,14 @@ export const signFieldWithToken = async ({
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
type:
|
||||
assistant && field.recipientId !== assistant.id
|
||||
? DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED
|
||||
: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
documentId: document.id,
|
||||
user: {
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
email: assistant?.email ?? recipient.email,
|
||||
name: assistant?.name ?? recipient.name,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import type { PDFDocument } from 'pdf-lib';
|
||||
import { RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
|
||||
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
@ -36,6 +36,9 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
);
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
const isDebugMode =
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
|
||||
|
||||
pdf.registerFontkit(fontkit);
|
||||
|
||||
@ -83,6 +86,35 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
||||
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
||||
|
||||
// Draw debug box if debug mode is enabled
|
||||
if (isDebugMode) {
|
||||
let debugX = fieldX;
|
||||
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
debugX,
|
||||
debugY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
debugX = adjustedPosition.xPos;
|
||||
debugY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
page.drawRectangle({
|
||||
x: debugX,
|
||||
y: debugY,
|
||||
width: fieldWidth,
|
||||
height: fieldHeight,
|
||||
borderColor: rgb(1, 0, 0), // Red
|
||||
borderWidth: 1,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
}
|
||||
|
||||
const font = await pdf.embedFont(
|
||||
isSignatureField ? fontCaveat : fontNoto,
|
||||
isSignatureField ? { features: { calt: false } } : undefined,
|
||||
@ -278,6 +310,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
|
||||
|
||||
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
|
||||
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
|
||||
const longestLineInTextForWidth = field.customText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
@ -293,7 +326,17 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
|
||||
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
|
||||
let textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
|
||||
const padding = 8; // PDF points, roughly equivalent to 0.5rem
|
||||
|
||||
// Calculate X position based on text alignment with padding
|
||||
let textX = fieldX + padding; // Left alignment starts after padding
|
||||
if (textAlign === 'center') {
|
||||
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
|
||||
} else if (textAlign === 'right') {
|
||||
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
|
||||
}
|
||||
|
||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
|
||||
@ -9,5 +9,8 @@ export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions)
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export interface GetRecipientsForAssistantOptions {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssistantOptions) => {
|
||||
const assistant = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!assistant) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Assistant not found',
|
||||
});
|
||||
}
|
||||
|
||||
let recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: assistant.documentId,
|
||||
signingOrder: {
|
||||
gte: assistant.signingOrder ?? 0,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
fields: {
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
recipientId: assistant.id,
|
||||
},
|
||||
{
|
||||
type: {
|
||||
not: FieldType.SIGNATURE,
|
||||
},
|
||||
documentId: assistant.documentId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Omit the token for recipients other than the assistant so
|
||||
// it doesn't get sent to the client.
|
||||
recipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
token: recipient.id === assistant.id ? token : '',
|
||||
}));
|
||||
|
||||
return recipients;
|
||||
};
|
||||
@ -95,7 +95,7 @@ export const createTeam = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await tx.team.create({
|
||||
const team = await tx.team.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
@ -104,13 +104,23 @@ export const createTeam = async ({
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId,
|
||||
userId: user.id,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamGlobalSettings.upsert({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
@ -225,6 +235,16 @@ export const createTeamFromPendingTeam = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamGlobalSettings.upsert({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id),
|
||||
);
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentDistributionMethod } from '@documenso/prisma/client';
|
||||
@ -17,7 +19,20 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import { ZFieldMetaSchema } from '../../types/field-meta';
|
||||
import type {
|
||||
TCheckboxFieldMeta,
|
||||
TDropdownFieldMeta,
|
||||
TFieldMetaPrefillFieldsSchema,
|
||||
TNumberFieldMeta,
|
||||
TRadioFieldMeta,
|
||||
TTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
ZFieldMetaSchema,
|
||||
ZRadioFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
@ -50,6 +65,7 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
email: string;
|
||||
signingOrder?: number | null;
|
||||
}[];
|
||||
prefillFields?: TFieldMetaPrefillFieldsSchema[];
|
||||
customDocumentDataId?: string;
|
||||
|
||||
/**
|
||||
@ -72,6 +88,165 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillFieldsSchema) => {
|
||||
if (!prefillField) {
|
||||
return field.fieldMeta;
|
||||
}
|
||||
|
||||
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(field.type);
|
||||
|
||||
if (!advancedField) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Field ${field.id} is not an advanced field and cannot have field meta information. Allowed types: NUMBER, RADIO, CHECKBOX, DROPDOWN, TEXT.`,
|
||||
});
|
||||
}
|
||||
|
||||
// We've already validated that the field types match at a higher level
|
||||
// Start with the existing field meta or an empty object
|
||||
const existingMeta = field.fieldMeta || {};
|
||||
|
||||
// Apply type-specific updates based on the prefill field type using ts-pattern
|
||||
return match(prefillField)
|
||||
.with({ type: 'text' }, (field) => {
|
||||
if (typeof field.value !== 'string') {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid value for TEXT field ${field.id}: expected string, got ${typeof field.value}`,
|
||||
});
|
||||
}
|
||||
|
||||
const meta: TTextFieldMeta = {
|
||||
...existingMeta,
|
||||
type: 'text',
|
||||
label: field.label,
|
||||
text: field.value,
|
||||
};
|
||||
|
||||
return meta;
|
||||
})
|
||||
.with({ type: 'number' }, (field) => {
|
||||
if (typeof field.value !== 'string') {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid value for NUMBER field ${field.id}: expected string, got ${typeof field.value}`,
|
||||
});
|
||||
}
|
||||
|
||||
const meta: TNumberFieldMeta = {
|
||||
...existingMeta,
|
||||
type: 'number',
|
||||
label: field.label,
|
||||
value: field.value,
|
||||
};
|
||||
|
||||
return meta;
|
||||
})
|
||||
.with({ type: 'radio' }, (field) => {
|
||||
if (typeof field.value !== 'string') {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid value for RADIO field ${field.id}: expected string, got ${typeof field.value}`,
|
||||
});
|
||||
}
|
||||
|
||||
const result = ZRadioFieldMeta.safeParse(existingMeta);
|
||||
|
||||
if (!result.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid field meta for RADIO field ${field.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const radioMeta = result.data;
|
||||
|
||||
// Validate that the value exists in the options
|
||||
const valueExists = radioMeta.values?.some((option) => option.value === field.value);
|
||||
|
||||
if (!valueExists) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Value "${field.value}" not found in options for RADIO field ${field.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const newValues = radioMeta.values?.map((option) => ({
|
||||
...option,
|
||||
checked: option.value === field.value,
|
||||
}));
|
||||
|
||||
const meta: TRadioFieldMeta = {
|
||||
...existingMeta,
|
||||
type: 'radio',
|
||||
label: field.label,
|
||||
values: newValues,
|
||||
};
|
||||
|
||||
return meta;
|
||||
})
|
||||
.with({ type: 'checkbox' }, (field) => {
|
||||
const result = ZCheckboxFieldMeta.safeParse(existingMeta);
|
||||
|
||||
if (!result.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid field meta for CHECKBOX field ${field.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const checkboxMeta = result.data;
|
||||
|
||||
// Validate that all values exist in the options
|
||||
for (const value of field.value) {
|
||||
const valueExists = checkboxMeta.values?.some((option) => option.value === value);
|
||||
|
||||
if (!valueExists) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Value "${value}" not found in options for CHECKBOX field ${field.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newValues = checkboxMeta.values?.map((option) => ({
|
||||
...option,
|
||||
checked: field.value.includes(option.value),
|
||||
}));
|
||||
|
||||
const meta: TCheckboxFieldMeta = {
|
||||
...existingMeta,
|
||||
type: 'checkbox',
|
||||
label: field.label,
|
||||
values: newValues,
|
||||
};
|
||||
|
||||
return meta;
|
||||
})
|
||||
.with({ type: 'dropdown' }, (field) => {
|
||||
const result = ZDropdownFieldMeta.safeParse(existingMeta);
|
||||
|
||||
if (!result.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Invalid field meta for DROPDOWN field ${field.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const dropdownMeta = result.data;
|
||||
|
||||
// Validate that the value exists in the options if values are defined
|
||||
const valueExists = dropdownMeta.values?.some((option) => option.value === field.value);
|
||||
|
||||
if (!valueExists) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Value "${field.value}" not found in options for DROPDOWN field ${field.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const meta: TDropdownFieldMeta = {
|
||||
...existingMeta,
|
||||
type: 'dropdown',
|
||||
label: field.label,
|
||||
defaultValue: field.value,
|
||||
};
|
||||
|
||||
return meta;
|
||||
})
|
||||
.otherwise(() => field.fieldMeta);
|
||||
};
|
||||
|
||||
export const createDocumentFromTemplate = async ({
|
||||
templateId,
|
||||
externalId,
|
||||
@ -81,6 +256,7 @@ export const createDocumentFromTemplate = async ({
|
||||
customDocumentDataId,
|
||||
override,
|
||||
requestMetadata,
|
||||
prefillFields,
|
||||
}: CreateDocumentFromTemplateOptions) => {
|
||||
const template = await prisma.template.findUnique({
|
||||
where: {
|
||||
@ -258,6 +434,47 @@ export const createDocumentFromTemplate = async ({
|
||||
|
||||
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
|
||||
|
||||
// Get all template field IDs first so we can validate later
|
||||
const allTemplateFieldIds = finalRecipients.flatMap((recipient) =>
|
||||
recipient.fields.map((field) => field.id),
|
||||
);
|
||||
|
||||
if (prefillFields?.length) {
|
||||
// Validate that all prefill field IDs exist in the template
|
||||
const invalidFieldIds = prefillFields
|
||||
.map((prefillField) => prefillField.id)
|
||||
.filter((id) => !allTemplateFieldIds.includes(id));
|
||||
|
||||
if (invalidFieldIds.length > 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `The following field IDs do not exist in the template: ${invalidFieldIds.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate that all prefill fields have the correct type
|
||||
for (const prefillField of prefillFields) {
|
||||
const templateField = finalRecipients
|
||||
.flatMap((recipient) => recipient.fields)
|
||||
.find((field) => field.id === prefillField.id);
|
||||
|
||||
if (!templateField) {
|
||||
// This should never happen due to the previous validation, but just in case
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Field with ID ${prefillField.id} not found in the template`,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedType = templateField.type.toLowerCase();
|
||||
const actualType = prefillField.type;
|
||||
|
||||
if (expectedType !== actualType) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Field type mismatch for field ${prefillField.id}: expected ${expectedType}, got ${actualType}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.values(finalRecipients).forEach(({ email, fields }) => {
|
||||
const recipient = document.recipients.find((recipient) => recipient.email === email);
|
||||
|
||||
@ -266,19 +483,25 @@ export const createDocumentFromTemplate = async ({
|
||||
}
|
||||
|
||||
fieldsToCreate = fieldsToCreate.concat(
|
||||
fields.map((field) => ({
|
||||
documentId: document.id,
|
||||
recipientId: recipient.id,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
})),
|
||||
fields.map((field) => {
|
||||
const prefillField = prefillFields?.find((value) => value.id === field.id);
|
||||
// Use type assertion to help TypeScript understand the structure
|
||||
const updatedFieldMeta = getUpdatedFieldMeta(field, prefillField);
|
||||
|
||||
return {
|
||||
documentId: document.id,
|
||||
recipientId: recipient.id,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: updatedFieldMeta,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
6965
packages/lib/translations/it/web.po
Normal file
6965
packages/lib/translations/it/web.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -28,6 +28,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_DELETED', // When the document is soft deleted.
|
||||
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
|
||||
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
|
||||
'DOCUMENT_FIELD_PREFILLED', // When a field is prefilled by an assistant.
|
||||
'DOCUMENT_VISIBILITY_UPDATED', // When the document visibility scope is updated
|
||||
'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated.
|
||||
'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated.
|
||||
@ -45,6 +46,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
'SIGNING_REQUEST',
|
||||
'VIEW_REQUEST',
|
||||
'APPROVE_REQUEST',
|
||||
'ASSISTING_REQUEST',
|
||||
'CC',
|
||||
'DOCUMENT_COMPLETED',
|
||||
]);
|
||||
@ -313,6 +315,83 @@ export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document field prefilled by assistant.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentFieldPrefilledSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
fieldId: z.string(),
|
||||
|
||||
// Organised into union to allow us to extend each field if required.
|
||||
field: z.union([
|
||||
z.object({
|
||||
type: z.literal(FieldType.INITIALS),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.EMAIL),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.DATE),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.NAME),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.TEXT),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.RADIO),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.CHECKBOX),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.DROPDOWN),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(FieldType.NUMBER),
|
||||
data: z.string(),
|
||||
}),
|
||||
]),
|
||||
fieldSecurity: z.preprocess(
|
||||
(input) => {
|
||||
const legacyNoneSecurityType = JSON.stringify({
|
||||
type: 'NONE',
|
||||
});
|
||||
|
||||
// Replace legacy 'NONE' field security type with undefined.
|
||||
if (
|
||||
typeof input === 'object' &&
|
||||
input !== null &&
|
||||
JSON.stringify(input) === legacyNoneSecurityType
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return input;
|
||||
},
|
||||
z
|
||||
.object({
|
||||
type: ZRecipientActionAuthTypesSchema,
|
||||
})
|
||||
.optional(),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventDocumentVisibilitySchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED),
|
||||
data: ZGenericFromToSchema,
|
||||
@ -493,6 +572,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
|
||||
ZDocumentAuditLogEventDocumentFieldPrefilledSchema,
|
||||
ZDocumentAuditLogEventDocumentVisibilitySchema,
|
||||
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
|
||||
ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema,
|
||||
|
||||
@ -11,9 +11,14 @@ export const ZBaseFieldMeta = z.object({
|
||||
|
||||
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
|
||||
|
||||
export const ZFieldTextAlignSchema = z.enum(['left', 'center', 'right']);
|
||||
|
||||
export type TFieldTextAlignSchema = z.infer<typeof ZFieldTextAlignSchema>;
|
||||
|
||||
export const ZInitialsFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('initials'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>;
|
||||
@ -21,6 +26,7 @@ export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>;
|
||||
export const ZNameFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('name'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
|
||||
@ -28,6 +34,7 @@ export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
|
||||
export const ZEmailFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('email'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
|
||||
@ -35,6 +42,7 @@ export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
|
||||
export const ZDateFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('date'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TDateFieldMeta = z.infer<typeof ZDateFieldMeta>;
|
||||
@ -44,6 +52,7 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({
|
||||
text: z.string().optional(),
|
||||
characterLimit: z.number().optional(),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TTextFieldMeta = z.infer<typeof ZTextFieldMeta>;
|
||||
@ -55,6 +64,7 @@ export const ZNumberFieldMeta = ZBaseFieldMeta.extend({
|
||||
minValue: z.number().optional(),
|
||||
maxValue: z.number().optional(),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TNumberFieldMeta = z.infer<typeof ZNumberFieldMeta>;
|
||||
@ -113,6 +123,42 @@ export const ZFieldMetaNotOptionalSchema = z.discriminatedUnion('type', [
|
||||
|
||||
export type TFieldMetaNotOptionalSchema = z.infer<typeof ZFieldMetaNotOptionalSchema>;
|
||||
|
||||
export const ZFieldMetaPrefillFieldsSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
})
|
||||
.and(
|
||||
z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('text'),
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('number'),
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('radio'),
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('checkbox'),
|
||||
label: z.string(),
|
||||
value: z.array(z.string()),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('dropdown'),
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
export type TFieldMetaPrefillFieldsSchema = z.infer<typeof ZFieldMetaPrefillFieldsSchema>;
|
||||
|
||||
export const ZFieldMetaSchema = z
|
||||
.union([
|
||||
// Handles an empty object being provided as fieldMeta.
|
||||
|
||||
@ -314,6 +314,10 @@ export const formatDocumentAuditLogAction = (
|
||||
anonymous: msg`Field unsigned`,
|
||||
identified: msg`${prefix} unsigned a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
|
||||
anonymous: msg`Field prefilled by assistant`,
|
||||
identified: msg`${prefix} prefilled a field`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
|
||||
anonymous: msg`Document visibility updated`,
|
||||
identified: msg`${prefix} updated the document visibility`,
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "RecipientRole" ADD VALUE 'ASSISTANT';
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_CANCELLED';
|
||||
@ -0,0 +1,18 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `expires` on the `Session` table. All the data in the column will be lost.
|
||||
- Added the required column `expiresAt` to the `Session` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Account" ADD COLUMN "password" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Session" DROP COLUMN "expires",
|
||||
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
ADD COLUMN "ipAddress" TEXT,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
ADD COLUMN "userAgent" TEXT;
|
||||
@ -175,6 +175,7 @@ enum WebhookTriggerEvents {
|
||||
DOCUMENT_SIGNED
|
||||
DOCUMENT_COMPLETED
|
||||
DOCUMENT_REJECTED
|
||||
DOCUMENT_CANCELLED
|
||||
}
|
||||
|
||||
model Webhook {
|
||||
@ -269,18 +270,25 @@ model Account {
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
password String?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId Int
|
||||
expires DateTime
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum DocumentStatus {
|
||||
@ -416,6 +424,7 @@ enum RecipientRole {
|
||||
SIGNER
|
||||
VIEWER
|
||||
APPROVER
|
||||
ASSISTANT
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
|
||||
|
||||
@ -5,6 +5,18 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash';
|
||||
|
||||
import { prisma } from '..';
|
||||
import { DocumentDataType, DocumentSource, Role, TeamMemberRole } from '../client';
|
||||
import { seedPendingDocument } from './documents';
|
||||
import { seedDirectTemplate, seedTemplate } from './templates';
|
||||
|
||||
const createDocumentData = async ({ documentData }: { documentData: string }) => {
|
||||
return prisma.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: documentData,
|
||||
initialData: documentData,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const seedDatabase = async () => {
|
||||
const examplePdf = fs
|
||||
@ -39,35 +51,80 @@ export const seedDatabase = async () => {
|
||||
update: {},
|
||||
});
|
||||
|
||||
const examplePdfData = await prisma.documentData.upsert({
|
||||
where: {
|
||||
id: 'clmn0kv5k0000pe04vcqg5zla',
|
||||
},
|
||||
create: {
|
||||
id: 'clmn0kv5k0000pe04vcqg5zla',
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: examplePdf,
|
||||
initialData: examplePdf,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const documentData = await createDocumentData({ documentData: examplePdf });
|
||||
|
||||
await prisma.document.create({
|
||||
data: {
|
||||
source: DocumentSource.DOCUMENT,
|
||||
title: 'Example Document',
|
||||
documentDataId: examplePdfData.id,
|
||||
userId: exampleUser.id,
|
||||
recipients: {
|
||||
create: {
|
||||
name: String(adminUser.name),
|
||||
email: adminUser.email,
|
||||
token: Math.random().toString(36).slice(2, 9),
|
||||
await prisma.document.create({
|
||||
data: {
|
||||
source: DocumentSource.DOCUMENT,
|
||||
title: `Example Document ${i}`,
|
||||
documentDataId: documentData.id,
|
||||
userId: exampleUser.id,
|
||||
recipients: {
|
||||
create: {
|
||||
name: String(adminUser.name),
|
||||
email: adminUser.email,
|
||||
token: Math.random().toString(36).slice(2, 9),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const documentData = await createDocumentData({ documentData: examplePdf });
|
||||
|
||||
await prisma.document.create({
|
||||
data: {
|
||||
source: DocumentSource.DOCUMENT,
|
||||
title: `Document ${i}`,
|
||||
documentDataId: documentData.id,
|
||||
userId: adminUser.id,
|
||||
recipients: {
|
||||
create: {
|
||||
name: String(exampleUser.name),
|
||||
email: exampleUser.email,
|
||||
token: Math.random().toString(36).slice(2, 9),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await seedPendingDocument(exampleUser, [adminUser], {
|
||||
key: 'example-pending',
|
||||
createDocumentOptions: {
|
||||
title: 'Pending Document',
|
||||
},
|
||||
});
|
||||
|
||||
await seedPendingDocument(adminUser, [exampleUser], {
|
||||
key: 'admin-pending',
|
||||
createDocumentOptions: {
|
||||
title: 'Pending Document',
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
seedTemplate({
|
||||
title: 'Template 1',
|
||||
userId: exampleUser.id,
|
||||
}),
|
||||
seedDirectTemplate({
|
||||
title: 'Direct Template 1',
|
||||
userId: exampleUser.id,
|
||||
}),
|
||||
|
||||
seedTemplate({
|
||||
title: 'Template 1',
|
||||
userId: adminUser.id,
|
||||
}),
|
||||
seedDirectTemplate({
|
||||
title: 'Direct Template 1',
|
||||
userId: adminUser.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const testUsers = [
|
||||
'test@documenso.com',
|
||||
'test2@documenso.com',
|
||||
|
||||
5
packages/prisma/types/recipient-with-fields.ts
Normal file
5
packages/prisma/types/recipient-with-fields.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
|
||||
export type RecipientWithFields = Recipient & {
|
||||
fields: Field[];
|
||||
};
|
||||
@ -1,5 +1,8 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import {
|
||||
@ -25,6 +28,7 @@ import type { Document } from '@documenso/prisma/client';
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
||||
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZBulkSendTemplateMutationSchema,
|
||||
ZCreateDocumentFromDirectTemplateRequestSchema,
|
||||
ZCreateDocumentFromTemplateRequestSchema,
|
||||
ZCreateDocumentFromTemplateResponseSchema,
|
||||
@ -223,7 +227,8 @@ export const templateRouter = router({
|
||||
.output(ZCreateDocumentFromTemplateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { teamId } = ctx;
|
||||
const { templateId, recipients, distributeDocument, customDocumentDataId } = input;
|
||||
const { templateId, recipients, distributeDocument, customDocumentDataId, prefillFields } =
|
||||
input;
|
||||
|
||||
const limits = await getServerLimits({ email: ctx.user.email, teamId });
|
||||
|
||||
@ -238,6 +243,7 @@ export const templateRouter = router({
|
||||
recipients,
|
||||
customDocumentDataId,
|
||||
requestMetadata: ctx.metadata,
|
||||
prefillFields,
|
||||
});
|
||||
|
||||
if (distributeDocument) {
|
||||
@ -414,4 +420,48 @@ export const templateRouter = router({
|
||||
userId,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
uploadBulkSend: authenticatedProcedure
|
||||
.input(ZBulkSendTemplateMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { templateId, teamId, csv, sendImmediately } = input;
|
||||
const { user } = ctx;
|
||||
|
||||
if (csv.length > 4 * 1024 * 1024) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'File size exceeds 4MB limit',
|
||||
});
|
||||
}
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Template not found',
|
||||
});
|
||||
}
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.bulk-send-template',
|
||||
payload: {
|
||||
userId: user.id,
|
||||
teamId,
|
||||
templateId,
|
||||
csvContent: csv,
|
||||
sendImmediately,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
ZDocumentActionAuthTypesSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import {
|
||||
ZTemplateLiteSchema,
|
||||
@ -67,6 +68,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
||||
'The data ID of an alternative PDF to use when creating the document. If not provided, the PDF attached to the template will be used.',
|
||||
)
|
||||
.optional(),
|
||||
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
|
||||
});
|
||||
|
||||
export const ZCreateDocumentFromTemplateResponseSchema = ZDocumentSchema;
|
||||
@ -188,6 +190,14 @@ export const ZMoveTemplateToTeamRequestSchema = z.object({
|
||||
|
||||
export const ZMoveTemplateToTeamResponseSchema = ZTemplateLiteSchema;
|
||||
|
||||
export const ZBulkSendTemplateMutationSchema = z.object({
|
||||
templateId: z.number(),
|
||||
teamId: z.number().optional(),
|
||||
csv: z.string().min(1),
|
||||
sendImmediately: z.boolean(),
|
||||
});
|
||||
|
||||
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
|
||||
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
|
||||
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
|
||||
export type TBulkSendTemplateMutationSchema = z.infer<typeof ZBulkSendTemplateMutationSchema>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user