mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
Merge branch 'main' into feat/template-document-name-options
This commit is contained in:
+1
-1
@@ -42,8 +42,8 @@ Documenso is an open-source document signing platform built as a **monorepo** us
|
|||||||
| Package | Description | Port |
|
| Package | Description | Port |
|
||||||
| -------------------------- | -------------------------------------------------------- | ---- |
|
| -------------------------- | -------------------------------------------------------- | ---- |
|
||||||
| `@documenso/remix` | Main application - React Router (Remix) with Hono server | 3000 |
|
| `@documenso/remix` | Main application - React Router (Remix) with Hono server | 3000 |
|
||||||
| `@documenso/documentation` | Documentation site (Next.js + Nextra) | 3002 |
|
|
||||||
| `@documenso/openpage-api` | Public analytics API | 3003 |
|
| `@documenso/openpage-api` | Public analytics API | 3003 |
|
||||||
|
| `@documenso/docs` | Documentation site | 3004 |
|
||||||
|
|
||||||
### Core Packages (`packages/`)
|
### Core Packages (`packages/`)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ description: Create, manage, and send documents for signing via the API.
|
|||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
||||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ description: Complete reference for the Documenso REST API.
|
|||||||
|
|
||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
The guides below cover common API patterns but may not reflect the latest endpoints or parameters.
|
The guides below cover common API patterns but may not reflect the latest endpoints or parameters.
|
||||||
For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com).
|
For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"teams",
|
"teams",
|
||||||
"rate-limits",
|
"rate-limits",
|
||||||
"versioning",
|
"versioning",
|
||||||
|
"migrate-to-envelopes",
|
||||||
"developer-mode",
|
"developer-mode",
|
||||||
"common-errors"
|
"common-errors"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
---
|
||||||
|
title: Migrating to Envelopes
|
||||||
|
description: Why Documenso unified documents and templates into envelopes, and how to migrate from the deprecated document and template create endpoints.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||||
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||||
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The following items have been deprecated and will be removed on the <strong>1st of March 2027</strong>:
|
||||||
|
|
||||||
|
- <strong>API V1</strong>
|
||||||
|
- <strong>A subset of SDK/API V2 endpoints</strong>
|
||||||
|
- <strong>Legacy documents and templates</strong>
|
||||||
|
- <strong>EmbedCreateDocumentV1</strong>
|
||||||
|
- <strong>EmbedCreateTemplateV1</strong>
|
||||||
|
- <strong>EmbedUpdateDocumentV1</strong>
|
||||||
|
- <strong>EmbedUpdateTemplateV1</strong>
|
||||||
|
|
||||||
|
The beta endpoint `/api/v2-beta` will also be removed. Use `/api/v2` instead, which is a drop-in replacement.
|
||||||
|
|
||||||
|
Nothing breaks before 1st of March 2027, so you can migrate at your own pace.
|
||||||
|
|
||||||
|
## What are legacy documents and templates
|
||||||
|
|
||||||
|
These are documents and templates created by the following endpoints:
|
||||||
|
|
||||||
|
- `POST /api/v2/document/create`
|
||||||
|
- `POST /api/v2/document/create/beta`
|
||||||
|
- `POST /api/v2/template/create`
|
||||||
|
- `POST /api/v2/template/create/beta`
|
||||||
|
- `POST /api/v1/documents`
|
||||||
|
- `POST /api/v1/templates`
|
||||||
|
- `POST /api/v1/templates/create-document`
|
||||||
|
- `POST /api/v1/templates/generate-document`
|
||||||
|
|
||||||
|
## What replaces legacy documents and templates
|
||||||
|
|
||||||
|
At the end of 2025 we introduced a unified system for documents and templates, called <strong>envelopes</strong>.
|
||||||
|
|
||||||
|
We still reference documents and templates throughout the documentation and application to distinguish them, but internally they are envelopes.
|
||||||
|
|
||||||
|
Moving to the envelope system gives you:
|
||||||
|
|
||||||
|
- **Multiple PDFs in one envelope.** Send several documents to sign in a single request.
|
||||||
|
- **One API for documents and templates.** Learn one set of endpoints instead of two misaligned ones.
|
||||||
|
- **A better editor and signing experience** for you and your recipients.
|
||||||
|
|
||||||
|
## How to migrate
|
||||||
|
|
||||||
|
{/* prettier-ignore */}
|
||||||
|
<Steps>
|
||||||
|
<Step>
|
||||||
|
### Switch to the envelope endpoints
|
||||||
|
|
||||||
|
Replace each deprecated endpoint with its `/api/v2/envelope/*` equivalent from the [mapping tables](#endpoint-mapping-reference) below.
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
### Set the envelope `type` on create
|
||||||
|
|
||||||
|
A single endpoint, `POST /api/v2/envelope/create`, can create both documents and templates. Set `type` to `DOCUMENT` or `TEMPLATE`. You can now upload more than one PDF using the `files` field.
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
### Update how you store IDs
|
||||||
|
|
||||||
|
Envelope IDs are **strings** (for example `envelope_abc123`), not numbers. Update any code that stores, parses, or compares IDs.
|
||||||
|
</Step>
|
||||||
|
<Step>
|
||||||
|
### Test, then remove the old calls
|
||||||
|
|
||||||
|
Verify the new flow against your account, then delete the deprecated calls.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
The main data differences are as follows:
|
||||||
|
- ID format changed from number to string (e.g. `42` to `envelope_abc123`)
|
||||||
|
- pageNumber becomes page
|
||||||
|
- pageX becomes positionX
|
||||||
|
- pageY becomes positionY
|
||||||
|
|
||||||
|
See the [Documents API](/docs/developers/api/documents) and [Templates API](/docs/developers/api/templates) for the full envelope reference.
|
||||||
|
|
||||||
|
### Deprecated V1 API Endpoints
|
||||||
|
|
||||||
|
Full reference in the [V1 OpenAPI reference](https://openapi-v1.documenso.com).
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| -------------------------------------------------------- | ----------------------------------------------------- |
|
||||||
|
| `GET /api/v1/documents` | `GET /api/v2/envelope` |
|
||||||
|
| `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||||
|
| `POST /api/v1/documents` | `POST /api/v2/envelope/create` |
|
||||||
|
| `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/distribute` |
|
||||||
|
| `POST /api/v1/documents/{id}/resend` | `POST /api/v2/envelope/redistribute` |
|
||||||
|
| `DELETE /api/v1/documents/{id}` | `POST /api/v2/envelope/delete` |
|
||||||
|
| `GET /api/v1/documents/{id}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
|
||||||
|
| `POST /api/v1/documents/{id}/recipients` | `POST /api/v2/envelope/recipient/create-many` |
|
||||||
|
| `PATCH /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/update-many` |
|
||||||
|
| `DELETE /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/delete` |
|
||||||
|
| `POST /api/v1/documents/{id}/fields` | `POST /api/v2/envelope/field/create-many` |
|
||||||
|
| `PATCH /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/update-many` |
|
||||||
|
| `DELETE /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/delete` |
|
||||||
|
| `GET /api/v1/templates` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||||
|
| `GET /api/v1/templates/{id}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||||
|
| `POST /api/v1/templates` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||||
|
| `DELETE /api/v1/templates/{id}` | `POST /api/v2/envelope/delete` |
|
||||||
|
| `POST /api/v1/templates/{templateId}/create-document` | `POST /api/v2/envelope/use` |
|
||||||
|
| `POST /api/v1/templates/{templateId}/generate-document` | `POST /api/v2/envelope/use` |
|
||||||
|
|
||||||
|
### Deprecated V2 API Endpoints
|
||||||
|
|
||||||
|
Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
|
||||||
|
|
||||||
|
#### Documents
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ------------------------------------------------- | ----------------------------------------------------- |
|
||||||
|
| `GET /api/v2/document` | `GET /api/v2/envelope` |
|
||||||
|
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||||
|
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` |
|
||||||
|
| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` |
|
||||||
|
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
|
||||||
|
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
|
||||||
|
| `POST /api/v2/document/delete` | `POST /api/v2/envelope/delete` |
|
||||||
|
| `POST /api/v2/document/duplicate` | `POST /api/v2/envelope/duplicate` |
|
||||||
|
| `POST /api/v2/document/distribute` | `POST /api/v2/envelope/distribute` |
|
||||||
|
| `POST /api/v2/document/redistribute` | `POST /api/v2/envelope/redistribute` |
|
||||||
|
| `GET /api/v2/document/attachment` | `GET /api/v2/envelope/attachment` |
|
||||||
|
| `POST /api/v2/document/attachment/create` | `POST /api/v2/envelope/attachment/create` |
|
||||||
|
| `POST /api/v2/document/attachment/update` | `POST /api/v2/envelope/attachment/update` |
|
||||||
|
| `POST /api/v2/document/attachment/delete` | `POST /api/v2/envelope/attachment/delete` |
|
||||||
|
| `GET /api/v2/document/{documentId}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
|
||||||
|
| `GET /api/v2/document/{documentId}/download-beta` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
|
||||||
|
|
||||||
|
#### Templates
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ------------------------------------- | ------------------------------------------------ |
|
||||||
|
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
|
||||||
|
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
|
||||||
|
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` |
|
||||||
|
| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||||
|
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
|
||||||
|
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
|
||||||
|
| `POST /api/v2/template/duplicate` | `POST /api/v2/envelope/duplicate` |
|
||||||
|
| `POST /api/v2/template/delete` | `POST /api/v2/envelope/delete` |
|
||||||
|
| `POST /api/v2/template/use` | `POST /api/v2/envelope/use` |
|
||||||
|
| `POST /api/v2/template/direct/create` | **Pending replacement** |
|
||||||
|
| `POST /api/v2/template/direct/delete` | **Pending replacement** |
|
||||||
|
| `POST /api/v2/template/direct/toggle` | **Pending replacement** |
|
||||||
|
|
||||||
|
#### Document fields
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ----------------------------------------- | ----------------------------------------- |
|
||||||
|
| `GET /api/v2/document/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
|
||||||
|
| `POST /api/v2/document/field/create` | `POST /api/v2/envelope/field/create-many` |
|
||||||
|
| `POST /api/v2/document/field/create-many` | `POST /api/v2/envelope/field/create-many` |
|
||||||
|
| `POST /api/v2/document/field/update` | `POST /api/v2/envelope/field/update-many` |
|
||||||
|
| `POST /api/v2/document/field/update-many` | `POST /api/v2/envelope/field/update-many` |
|
||||||
|
| `POST /api/v2/document/field/delete` | `POST /api/v2/envelope/field/delete` |
|
||||||
|
|
||||||
|
#### Template fields
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ----------------------------------------- | ----------------------------------------- |
|
||||||
|
| `GET /api/v2/template/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
|
||||||
|
| `POST /api/v2/template/field/create` | `POST /api/v2/envelope/field/create-many` |
|
||||||
|
| `POST /api/v2/template/field/create-many` | `POST /api/v2/envelope/field/create-many` |
|
||||||
|
| `POST /api/v2/template/field/update` | `POST /api/v2/envelope/field/update-many` |
|
||||||
|
| `POST /api/v2/template/field/update-many` | `POST /api/v2/envelope/field/update-many` |
|
||||||
|
| `POST /api/v2/template/field/delete` | `POST /api/v2/envelope/field/delete` |
|
||||||
|
|
||||||
|
#### Document recipients
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ---------------------------------------------- | ---------------------------------------------- |
|
||||||
|
| `GET /api/v2/document/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
|
||||||
|
| `POST /api/v2/document/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
|
||||||
|
| `POST /api/v2/document/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
|
||||||
|
| `POST /api/v2/document/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
|
||||||
|
| `POST /api/v2/document/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
|
||||||
|
| `POST /api/v2/document/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
|
||||||
|
|
||||||
|
#### Template recipients
|
||||||
|
|
||||||
|
| Deprecated endpoint | Replacement |
|
||||||
|
| ---------------------------------------------- | ---------------------------------------------- |
|
||||||
|
| `GET /api/v2/template/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
|
||||||
|
| `POST /api/v2/template/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
|
||||||
|
| `POST /api/v2/template/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
|
||||||
|
| `POST /api/v2/template/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
|
||||||
|
| `POST /api/v2/template/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
|
||||||
|
| `POST /api/v2/template/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
|
||||||
|
|
||||||
|
### Embedding components
|
||||||
|
|
||||||
|
| Deprecated component | Replacement |
|
||||||
|
| ----------------------- | --------------------- |
|
||||||
|
| `EmbedCreateDocumentV1` | `EmbedCreateEnvelope` |
|
||||||
|
| `EmbedCreateTemplateV1` | `EmbedCreateEnvelope` |
|
||||||
|
| `EmbedUpdateDocumentV1` | `EmbedUpdateEnvelope` |
|
||||||
|
| `EmbedUpdateTemplateV1` | `EmbedUpdateEnvelope` |
|
||||||
|
|
||||||
|
See the [embedding guide](/docs/developers/embedding) for the envelope components.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
<Accordions>
|
||||||
|
<Accordion title="What happens on 1 March 2027?">
|
||||||
|
The deprecated V1 API, the V2 endpoints listed above, and the V1 embedding components are removed.
|
||||||
|
Requests to them will fail, so migrate to the envelope API before that date.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="Will my existing documents and templates keep working?">
|
||||||
|
Yes. Documents and templates you already created remain in your account and continue to work. They will automatically be converted to envelopes. Only
|
||||||
|
the deprecated endpoints you call are going away. Your data is not deleted.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="Do I need a new API token?">
|
||||||
|
No. Authentication is unchanged. The same API token works for the envelope endpoints under
|
||||||
|
`https://app.documenso.com/api/v2`.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="What is the difference between a document and a template now?">
|
||||||
|
Both are envelopes, distinguished by a `type` field of `DOCUMENT` or `TEMPLATE`. They share the same
|
||||||
|
endpoints, recipients, fields, and attachments.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="I use an official SDK, what should I do?">
|
||||||
|
The function calls to the legacy endpoints will break on the 1st of March 2027. Update to the latest SDK version and switch to its envelope methods.
|
||||||
|
The deprecated document and template methods map to the envelope endpoints in the tables above.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="I need more time or help migrating">
|
||||||
|
Reach out to [support@documenso.com](mailto:support@documenso.com) with your use case and we will
|
||||||
|
help you plan the migration.
|
||||||
|
</Accordion>
|
||||||
|
</Accordions>
|
||||||
|
|
||||||
|
## Getting help
|
||||||
|
|
||||||
|
- [V2 OpenAPI reference](https://openapi.documenso.com): the up-to-date envelope API.
|
||||||
|
- [V1 OpenAPI reference](https://openapi-v1.documenso.com): the deprecated V1 API.
|
||||||
|
- [support@documenso.com](mailto:support@documenso.com): migration questions and extensions.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Documents API](/docs/developers/api/documents): create and manage envelopes
|
||||||
|
- [Templates API](/docs/developers/api/templates): work with templates and direct links
|
||||||
|
- [Fields API](/docs/developers/api/fields) and [Recipients API](/docs/developers/api/recipients)
|
||||||
|
- [API Versioning](/docs/developers/api/versioning): how Documenso versions the public API
|
||||||
@@ -11,9 +11,14 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
|
|||||||
|
|
||||||
## HTTP Rate Limits
|
## HTTP Rate Limits
|
||||||
|
|
||||||
**Limit:** 100 requests per minute per IP address
|
**Limit:** 1000 requests per minute per IP address
|
||||||
**Response:** 429 Too Many Requests
|
**Response:** 429 Too Many Requests
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
This is the global per-IP ceiling. Your organisation may have its own rate limits configured below
|
||||||
|
this value, in which case you can be rate-limited before reaching the global limit.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
### Rate Limit Response
|
### Rate Limit Response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ description: Create documents from reusable templates via API.
|
|||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Callout type="warn">
|
<Callout type="warn">
|
||||||
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
|
||||||
see the [OpenAPI Reference](https://openapi.documenso.com).
|
see the [OpenAPI Reference](https://openapi.documenso.com).
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ description: Versioning information for the Documenso public API.
|
|||||||
|
|
||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Documenso uses API versioning to manage changes to the public API. This allows us to introduce new features, fix bugs, and make other changes without breaking existing integrations.
|
Documenso uses API versioning to manage changes to the public API. This allows us to introduce new features, fix bugs, and make other changes without breaking existing integrations.
|
||||||
@@ -19,7 +21,16 @@ Also, we may deprecate certain features or endpoints in the API. When we depreca
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Documents, Templates, and Envelopes
|
||||||
|
|
||||||
|
Documenso has unified documents and templates into a single resource called an **envelope**. New integrations should create documents and templates through the `/envelope/*` endpoints. The `POST /document/create` and `POST /template/create` endpoints (including their `/beta` variants) are deprecated in favor of `POST /envelope/create`.
|
||||||
|
|
||||||
|
See [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) for the rationale and step-by-step migration examples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
|
- [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) - Move from the document and template create endpoints
|
||||||
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
|
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
|
||||||
- [Rate Limits](/docs/developers/api/rate-limits) - API rate limit details
|
- [Rate Limits](/docs/developers/api/rate-limits) - API rate limit details
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Callout } from 'fumadocs-ui/components/callout';
|
|||||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
## Workflow 1: Send a Document for Signature
|
## Workflow 1: Send a Document for Signature
|
||||||
|
|
||||||
The most common workflow: upload a PDF, add recipients with signature fields, and send for signing.
|
The most common workflow: upload a PDF, add recipients with signature fields, and send for signing.
|
||||||
@@ -472,7 +474,7 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
|
|||||||
<code>distributeDocument: true</code>
|
<code>distributeDocument: true</code>
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
Process in batches with a short delay to respect rate limits (e.g. 100 requests/minute)
|
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
|
||||||
</Step>
|
</Step>
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|
||||||
@@ -638,8 +640,8 @@ done
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
The API allows 100 requests per minute. For large batches, implement rate limiting with delays
|
The API allows 1000 requests per minute (your organisation may have its own lower limit). For large
|
||||||
between requests to avoid hitting limits.
|
batches, implement rate limiting with delays between requests to avoid hitting limits.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ title: Examples
|
|||||||
description: Common integration patterns and end-to-end workflows.
|
description: Common integration patterns and end-to-end workflows.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Cards>
|
<Cards>
|
||||||
<Card
|
<Card
|
||||||
title="Common Workflows"
|
title="Common Workflows"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
|||||||
import { Callout } from 'fumadocs-ui/components/callout';
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- A Documenso account (cloud or self-hosted)
|
- A Documenso account (cloud or self-hosted)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Callout } from 'fumadocs-ui/components/callout';
|
|||||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Before starting, you need:
|
Before starting, you need:
|
||||||
@@ -483,7 +485,7 @@ The API returns standard HTTP status codes and JSON error responses:
|
|||||||
|
|
||||||
### Handling Rate Limits
|
### Handling Rate Limits
|
||||||
|
|
||||||
The API allows 100 requests per minute per IP address. When rate limited, wait at least 60 seconds before retrying:
|
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
async function fetchWithRetry(url, options, maxRetries = 3) {
|
async function fetchWithRetry(url, options, maxRetries = 3) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ title: Getting Started
|
|||||||
description: Get your API key and make your first API call.
|
description: Get your API key and make your first API call.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
<Cards>
|
<Cards>
|
||||||
<Card
|
<Card
|
||||||
title="Authentication"
|
title="Authentication"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ title: Developer Guide
|
|||||||
description: Integrate Documenso into your applications using the REST API, webhooks, and embedding options.
|
description: Integrate Documenso into your applications using the REST API, webhooks, and embedding options.
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<EnvelopeWarning />
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
<Cards>
|
<Cards>
|
||||||
|
|||||||
@@ -41,12 +41,17 @@ When a limit is reached, requests return a `429 Too Many Requests` response with
|
|||||||
|
|
||||||
| Action | Limit | Window |
|
| Action | Limit | Window |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| API requests (v1 and v2) | 100 requests | 1 minute |
|
| API requests (v1 and v2) | 1000 requests | 1 minute |
|
||||||
| File uploads | 20 requests | 1 minute |
|
| File uploads | 20 requests | 1 minute |
|
||||||
| AI features | 3 requests | 1 minute |
|
| AI features | 3 requests | 1 minute |
|
||||||
|
|
||||||
Authentication endpoints (login, signup, password reset, etc.) are also rate-limited to protect against abuse.
|
Authentication endpoints (login, signup, password reset, etc.) are also rate-limited to protect against abuse.
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
The API request limit above is the global per-IP ceiling. Individual organisations also have their
|
||||||
|
own rate limits, which may be configured below this value.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
Rate limits may vary by plan. Enterprise plans can include higher or custom limits. Contact
|
Rate limits may vary by plan. Enterprise plans can include higher or custom limits. Contact
|
||||||
[sales](https://documen.so/sales) for details.
|
[sales](https://documen.so/sales) for details.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ There are three distinct kinds of limit:
|
|||||||
| ---------------------- | ------------------------------------------------- | ----------------------- |
|
| ---------------------- | ------------------------------------------------- | ----------------------- |
|
||||||
| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org |
|
| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org |
|
||||||
| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org |
|
| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org |
|
||||||
| Global HTTP rate limit | API requests per IP (100/min, hardcoded) | No — see [Limitations](#limitations) |
|
| Global HTTP rate limit | API requests per IP (1000/min, hardcoded) | No — see [Limitations](#limitations) |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ Monthly quota usage is keyed to the **UTC calendar month**. There is no schedule
|
|||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **100 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
|
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **1000 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "NEXT_IGNORE_INCORRECT_LOCKFILE=true next build",
|
"build": "next build",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
|
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Callout } from 'fumadocs-ui/components/callout';
|
||||||
|
|
||||||
|
const MIGRATION_GUIDE_HREF = '/docs/developers/api/migrate-to-envelopes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deprecation banner steering API consumers away from the legacy document and
|
||||||
|
* template create endpoints and towards the unified Envelope API.
|
||||||
|
*
|
||||||
|
* Registered globally in `mdx-components.tsx`, so it can be used in any MDX page
|
||||||
|
* as `<EnvelopeWarning />` without an explicit import.
|
||||||
|
*/
|
||||||
|
export function EnvelopeWarning() {
|
||||||
|
return (
|
||||||
|
<Callout type="error">
|
||||||
|
<strong>Documents and templates are being deprecated and replaced by envelopes.</strong>{' '}
|
||||||
|
<a href={MIGRATION_GUIDE_HREF}>Read the migration guide here.</a>
|
||||||
|
</Callout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as TabsComponents from 'fumadocs-ui/components/tabs';
|
import * as TabsComponents from 'fumadocs-ui/components/tabs';
|
||||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||||
import type { MDXComponents } from 'mdx/types';
|
import type { MDXComponents } from 'mdx/types';
|
||||||
|
import { EnvelopeWarning } from '@/components/mdx/envelope-warning';
|
||||||
import { Mermaid } from '@/components/mdx/mermaid';
|
import { Mermaid } from '@/components/mdx/mermaid';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -9,6 +10,7 @@ export function getMDXComponents(components?: MDXComponents): any {
|
|||||||
...defaultMdxComponents,
|
...defaultMdxComponents,
|
||||||
...TabsComponents,
|
...TabsComponents,
|
||||||
Mermaid,
|
Mermaid,
|
||||||
|
EnvelopeWarning,
|
||||||
...components,
|
...components,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type BrandingPreferencesResetDialogProps = {
|
||||||
|
hasAdvancedBranding: boolean;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onReset: () => Promise<void>;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BrandingPreferencesResetDialog = ({
|
||||||
|
hasAdvancedBranding,
|
||||||
|
isSubmitting,
|
||||||
|
onReset,
|
||||||
|
trigger,
|
||||||
|
}: BrandingPreferencesResetDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
|
const isLoading = isSubmitting || isResetting;
|
||||||
|
|
||||||
|
const handleResetToDefaults = async () => {
|
||||||
|
setIsResetting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onReset();
|
||||||
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
|
// The submit handler surfaces its own error toast. Keep the dialog open
|
||||||
|
// so the user can retry.
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
|
||||||
|
<Trans>Reset to defaults</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Reset branding preferences</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This will reset all branding preferences to their default values and save the changes immediately.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
<Trans>Once confirmed, the following will be reset:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
|
<li>
|
||||||
|
<Trans>Custom branding enabled setting</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Branding logo</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Brand website and brand details</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Brand colours, including background, foreground, primary, and border colours</Trans>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{hasAdvancedBranding && (
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<Trans>Border radius</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Custom CSS</Trans>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary" disabled={isLoading}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
|
||||||
|
<Trans>Reset to defaults</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export type DocumentPreferencesResetDialogProps = {
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onReset: () => Promise<void>;
|
||||||
|
showAiFeatures?: boolean;
|
||||||
|
showDocumentVisibility?: boolean;
|
||||||
|
showIncludeSenderDetails?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentPreferencesResetDialog = ({
|
||||||
|
isSubmitting,
|
||||||
|
onReset,
|
||||||
|
showAiFeatures = false,
|
||||||
|
showDocumentVisibility = false,
|
||||||
|
showIncludeSenderDetails = false,
|
||||||
|
}: DocumentPreferencesResetDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
|
const isLoading = isSubmitting || isResetting;
|
||||||
|
|
||||||
|
const handleResetToDefaults = async () => {
|
||||||
|
setIsResetting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onReset();
|
||||||
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
|
// The submit handler surfaces its own error toast. Keep the dialog open
|
||||||
|
// so the user can retry.
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
|
||||||
|
<Trans>Reset to defaults</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Reset document preferences</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
This will reset all document preferences to their default values and save the changes immediately.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
<Trans>Once confirmed, the following will be reset:</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-0.5 list-inside list-disc">
|
||||||
|
{showDocumentVisibility && (
|
||||||
|
<li>
|
||||||
|
<Trans>Default document visibility</Trans>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
<Trans>Default document language</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default date format</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default time zone</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default signature settings</Trans>
|
||||||
|
</li>
|
||||||
|
{showIncludeSenderDetails && (
|
||||||
|
<li>
|
||||||
|
<Trans>Send on behalf of team</Trans>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
<Trans>Include the signing certificate in the document</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Include the audit logs in the document</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default recipients</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Delegate document ownership</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default envelope expiration</Trans>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Trans>Default signing reminders</Trans>
|
||||||
|
</li>
|
||||||
|
{showAiFeatures && (
|
||||||
|
<li>
|
||||||
|
<Trans>AI features</Trans>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary" disabled={isLoading}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
|
||||||
|
<Trans>Reset to defaults</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@documenso/lib/constants/branding';
|
} from '@documenso/lib/constants/branding';
|
||||||
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
|
||||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||||
|
import { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -23,6 +24,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { BrandingPreferencesResetDialog } from '~/components/dialogs/branding-preferences-reset-dialog';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
import { useCspNonce } from '~/utils/nonce';
|
import { useCspNonce } from '~/utils/nonce';
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ export function BrandingPreferencesForm({
|
|||||||
|
|
||||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||||
|
const [colorPickerKey, setColorPickerKey] = useState(0);
|
||||||
|
|
||||||
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
|
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
|
||||||
const initialColors = parsedColors.success ? parsedColors.data : {};
|
const initialColors = parsedColors.success ? parsedColors.data : {};
|
||||||
@@ -96,6 +99,42 @@ export function BrandingPreferencesForm({
|
|||||||
|
|
||||||
const isBrandingEnabled = form.watch('brandingEnabled');
|
const isBrandingEnabled = form.watch('brandingEnabled');
|
||||||
|
|
||||||
|
const hasResetBrandingColors =
|
||||||
|
settings.brandingColors === null ||
|
||||||
|
settings.brandingColors === undefined ||
|
||||||
|
(parsedColors.success && normalizeBrandingColors(parsedColors.data) === null);
|
||||||
|
|
||||||
|
// Only show the reset action when the saved settings actually differ from the
|
||||||
|
// defaults, so it never renders as a pointless disabled button.
|
||||||
|
const isResetToDefaultsVisible =
|
||||||
|
settings.brandingEnabled !== (canInherit ? null : false) ||
|
||||||
|
!!settings.brandingLogo ||
|
||||||
|
!!settings.brandingUrl ||
|
||||||
|
!!settings.brandingCompanyDetails ||
|
||||||
|
!!settings.brandingCss ||
|
||||||
|
!hasResetBrandingColors;
|
||||||
|
|
||||||
|
const handleResetToDefaults = async () => {
|
||||||
|
const data: TBrandingPreferencesFormSchema = {
|
||||||
|
brandingEnabled: canInherit ? null : false,
|
||||||
|
brandingLogo: null,
|
||||||
|
brandingUrl: '',
|
||||||
|
brandingCompanyDetails: '',
|
||||||
|
brandingColors: {},
|
||||||
|
brandingCss: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await onFormSubmit(data);
|
||||||
|
|
||||||
|
if (previewUrl.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewUrl('');
|
||||||
|
setColorPickerKey((key) => key + 1);
|
||||||
|
form.reset(data);
|
||||||
|
};
|
||||||
|
|
||||||
const getSavedLogoPreviewUrl = () => {
|
const getSavedLogoPreviewUrl = () => {
|
||||||
if (!settings.brandingLogo) {
|
if (!settings.brandingLogo) {
|
||||||
return '';
|
return '';
|
||||||
@@ -397,6 +436,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`background-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.background}
|
defaultValue={DEFAULT_BRAND_COLORS.background}
|
||||||
@@ -420,6 +460,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`foreground-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.foreground}
|
defaultValue={DEFAULT_BRAND_COLORS.foreground}
|
||||||
@@ -443,6 +484,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`primary-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.primary}
|
defaultValue={DEFAULT_BRAND_COLORS.primary}
|
||||||
@@ -466,6 +508,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`primary-foreground-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
|
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
|
||||||
@@ -489,6 +532,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`border-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.border}
|
defaultValue={DEFAULT_BRAND_COLORS.border}
|
||||||
@@ -512,6 +556,7 @@ export function BrandingPreferencesForm({
|
|||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
|
key={`ring-${colorPickerKey}`}
|
||||||
nonce={nonce}
|
nonce={nonce}
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ''}
|
||||||
defaultValue={DEFAULT_BRAND_COLORS.ring}
|
defaultValue={DEFAULT_BRAND_COLORS.ring}
|
||||||
@@ -593,6 +638,15 @@ export function BrandingPreferencesForm({
|
|||||||
isDirty={hasUnsavedChanges}
|
isDirty={hasUnsavedChanges}
|
||||||
isSubmitting={form.formState.isSubmitting}
|
isSubmitting={form.formState.isSubmitting}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
|
resetToDefaults={
|
||||||
|
isResetToDefaultsVisible ? (
|
||||||
|
<BrandingPreferencesResetDialog
|
||||||
|
hasAdvancedBranding={hasAdvancedBranding}
|
||||||
|
isSubmitting={form.formState.isSubmitting}
|
||||||
|
onReset={handleResetToDefaults}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr
|
|||||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
|
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
|
||||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||||
import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
|
import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta';
|
||||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||||
@@ -37,11 +37,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { msg, t } from '@lingui/core/macro';
|
import { msg, t } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { TeamGlobalSettings } from '@prisma/client';
|
import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
|
||||||
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
||||||
@@ -93,6 +93,26 @@ export type DocumentPreferencesFormProps = {
|
|||||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => {
|
||||||
|
const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat);
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentVisibility: settings.documentVisibility,
|
||||||
|
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
|
||||||
|
documentTimezone: settings.documentTimezone,
|
||||||
|
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
|
||||||
|
includeSenderDetails: settings.includeSenderDetails,
|
||||||
|
includeSigningCertificate: settings.includeSigningCertificate,
|
||||||
|
includeAuditLog: settings.includeAuditLog,
|
||||||
|
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||||
|
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
|
||||||
|
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||||
|
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||||
|
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||||
|
reminderSettings: settings.reminderSettings ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const DocumentPreferencesForm = ({
|
export const DocumentPreferencesForm = ({
|
||||||
settings,
|
settings,
|
||||||
onFormSubmit,
|
onFormSubmit,
|
||||||
@@ -113,7 +133,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
||||||
documentTimezone: z.string().nullable(),
|
documentTimezone: z.string().nullable(),
|
||||||
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
|
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
|
||||||
includeSenderDetails: z.boolean().nullable(),
|
includeSenderDetails: z.boolean().nullable(),
|
||||||
includeSigningCertificate: z.boolean().nullable(),
|
includeSigningCertificate: z.boolean().nullable(),
|
||||||
includeAuditLog: z.boolean().nullable(),
|
includeAuditLog: z.boolean().nullable(),
|
||||||
@@ -127,26 +147,33 @@ export const DocumentPreferencesForm = ({
|
|||||||
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const defaultValues = getDocumentPreferencesFormValues(settings);
|
||||||
|
const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings();
|
||||||
|
const baseResetValues = getDocumentPreferencesFormValues(defaultSettings);
|
||||||
|
const resetValues = {
|
||||||
|
...baseResetValues,
|
||||||
|
aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled,
|
||||||
|
};
|
||||||
|
|
||||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||||
defaultValues: {
|
defaultValues,
|
||||||
documentVisibility: settings.documentVisibility,
|
|
||||||
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
|
|
||||||
documentTimezone: settings.documentTimezone,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
|
|
||||||
includeSenderDetails: settings.includeSenderDetails,
|
|
||||||
includeSigningCertificate: settings.includeSigningCertificate,
|
|
||||||
includeAuditLog: settings.includeAuditLog,
|
|
||||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
|
||||||
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
|
|
||||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
|
||||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
|
||||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
|
||||||
reminderSettings: settings.reminderSettings ?? null,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Parse both sides through the schema so we compare canonical representations
|
||||||
|
const parsedCurrentValues = ZDocumentPreferencesFormSchema.safeParse(defaultValues);
|
||||||
|
const parsedResetValues = ZDocumentPreferencesFormSchema.safeParse(resetValues);
|
||||||
|
|
||||||
|
const isResetToDefaultsVisible =
|
||||||
|
!parsedCurrentValues.success ||
|
||||||
|
!parsedResetValues.success ||
|
||||||
|
JSON.stringify(parsedCurrentValues.data) !== JSON.stringify(parsedResetValues.data);
|
||||||
|
|
||||||
|
const handleResetToDefaults = async () => {
|
||||||
|
await onFormSubmit(resetValues);
|
||||||
|
form.reset(resetValues);
|
||||||
|
};
|
||||||
|
|
||||||
const handleFormSubmit = form.handleSubmit(async (data) => {
|
const handleFormSubmit = form.handleSubmit(async (data) => {
|
||||||
try {
|
try {
|
||||||
await onFormSubmit(data);
|
await onFormSubmit(data);
|
||||||
@@ -772,6 +799,17 @@ export const DocumentPreferencesForm = ({
|
|||||||
isDirty={form.formState.isDirty}
|
isDirty={form.formState.isDirty}
|
||||||
isSubmitting={form.formState.isSubmitting}
|
isSubmitting={form.formState.isSubmitting}
|
||||||
onReset={() => form.reset()}
|
onReset={() => form.reset()}
|
||||||
|
resetToDefaults={
|
||||||
|
isResetToDefaultsVisible ? (
|
||||||
|
<DocumentPreferencesResetDialog
|
||||||
|
isSubmitting={form.formState.isSubmitting}
|
||||||
|
onReset={handleResetToDefaults}
|
||||||
|
showAiFeatures={isAiFeaturesConfigured}
|
||||||
|
showDocumentVisibility={!isPersonalLayoutMode}
|
||||||
|
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,12 +3,17 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { AlertTriangleIcon } from 'lucide-react';
|
import { AlertTriangleIcon } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
export type FormStickySaveBarProps = {
|
export type FormStickySaveBarProps = {
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
|
/**
|
||||||
|
* Slot for a "reset to defaults" action, rendered before the Undo button. Hidden while
|
||||||
|
* the bar is floating so it never appears in the unsaved-changes island.
|
||||||
|
*/
|
||||||
|
resetToDefaults?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +29,7 @@ export type FormStickySaveBarProps = {
|
|||||||
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
|
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
|
||||||
* the pill chrome.
|
* the pill chrome.
|
||||||
*/
|
*/
|
||||||
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
|
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefaults }: FormStickySaveBarProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -100,6 +105,8 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormSticky
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
|
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
|
||||||
|
{!isFloating && resetToDefaults}
|
||||||
|
|
||||||
{isDirty && (
|
{isDirty && (
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
|
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
|
||||||
<Trans>Undo</Trans>
|
<Trans>Undo</Trans>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export type PromptItem = {
|
||||||
|
id: string;
|
||||||
|
label: string | MessageDescriptor;
|
||||||
|
sublabel?: string;
|
||||||
|
path?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
initials?: string;
|
||||||
|
shortcut?: string;
|
||||||
|
isChecked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromptCategory = {
|
||||||
|
id: string;
|
||||||
|
label: MessageDescriptor;
|
||||||
|
items: PromptItem[];
|
||||||
|
/**
|
||||||
|
* The number of actual results, excluding utility rows such as the
|
||||||
|
* "View all results" link.
|
||||||
|
*/
|
||||||
|
count: number;
|
||||||
|
/**
|
||||||
|
* The count shown on the category chip, or null to not show a chip at all.
|
||||||
|
* Categories which only contain hardcoded page links have no chip.
|
||||||
|
*/
|
||||||
|
chipCount: number | null;
|
||||||
|
isCapped: boolean;
|
||||||
|
/**
|
||||||
|
* Global admin categories are marked with a globe icon to distinguish them
|
||||||
|
* from the equally named personal categories.
|
||||||
|
*/
|
||||||
|
isGlobal: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { AlertTriangleIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export const DirectTemplateInvalidPageView = () => {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
|
||||||
|
<div>
|
||||||
|
<AlertTriangleIcon className="h-10 w-10 text-destructive" />
|
||||||
|
|
||||||
|
<h1 className="mt-4 font-semibold text-3xl">
|
||||||
|
<Trans>Invalid direct link template</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
This direct link template cannot be used because one or more signers do not have a signature field assigned.
|
||||||
|
Please contact the sender to update the template.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -54,6 +54,7 @@ import { useCurrentTeam } from '~/providers/team';
|
|||||||
|
|
||||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||||
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
|
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
|
||||||
|
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||||
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
|
||||||
|
|
||||||
@@ -238,6 +239,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EnvelopeEditorInvalidDirectTemplateAlert />
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
<div className="mt-4 flex h-full flex-col items-center justify-center">
|
||||||
{envelope.recipients.length === 0 && (
|
{envelope.recipients.length === 0 && (
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
|
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export type EnvelopeEditorInvalidDirectTemplateAlertProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns that a direct link template cannot be used because one or more signers
|
||||||
|
* are missing a signature field.
|
||||||
|
*/
|
||||||
|
export const EnvelopeEditorInvalidDirectTemplateAlert = ({
|
||||||
|
className,
|
||||||
|
}: EnvelopeEditorInvalidDirectTemplateAlertProps) => {
|
||||||
|
const { envelope, isTemplate } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
|
const signersMissingSignatureFields = useMemo(() => {
|
||||||
|
if (!isTemplate || !envelope.directLink?.enabled) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
|
||||||
|
}, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]);
|
||||||
|
|
||||||
|
if (signersMissingSignatureFields.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
variant="destructive"
|
||||||
|
className={cn('mx-auto w-full max-w-[800px] flex-row items-start gap-3 rounded-sm', className)}
|
||||||
|
>
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Invalid direct link template</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
|
Recipients cannot use this direct link template because the following signers are missing a signature field
|
||||||
|
</Trans>
|
||||||
|
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
{signersMissingSignatureFields.map((recipient, i) => (
|
||||||
|
<li key={recipient.id}>{recipient.email || recipient.name || `Recipient ${i + 1}`}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ import { match } from 'ts-pattern';
|
|||||||
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
|
||||||
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
|
||||||
|
|
||||||
|
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||||
|
|
||||||
export const EnvelopeEditorPreviewPage = () => {
|
export const EnvelopeEditorPreviewPage = () => {
|
||||||
@@ -228,6 +229,8 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
|
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
|
||||||
|
|
||||||
|
<EnvelopeEditorInvalidDirectTemplateAlert className="mb-4" />
|
||||||
|
|
||||||
<Alert variant="warning" className="mx-auto max-w-[800px]">
|
<Alert variant="warning" className="mx-auto max-w-[800px]">
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>Preview Mode</Trans>
|
<Trans>Preview Mode</Trans>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from
|
|||||||
|
|
||||||
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
|
||||||
|
|
||||||
|
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
|
||||||
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
|
||||||
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||||
|
|
||||||
@@ -449,6 +450,9 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||||
<input {...getReplaceInputProps()} />
|
<input {...getReplaceInputProps()} />
|
||||||
|
|
||||||
|
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
|
||||||
|
|
||||||
<Card backdropBlur={false} className="border">
|
<Card backdropBlur={false} className="border">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||||
|
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import type { TAdminSearchResultType } from '@documenso/trpc/server/admin-router/admin-search.types';
|
||||||
|
import { ADMIN_SEARCH_MAX_QUERY_LENGTH } from '@documenso/trpc/server/admin-router/admin-search.types';
|
||||||
|
|
||||||
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { keepPreviousData } from '@tanstack/react-query';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { ArrowRightIcon, Building2Icon, CreditCardIcon, FileTextIcon, UserIcon, UsersIcon } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { PromptCategory, PromptItem } from './app-command-menu.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of results the admin search returns per resource type.
|
||||||
|
*/
|
||||||
|
const ADMIN_SEARCH_RESULTS_CAP = 5;
|
||||||
|
|
||||||
|
const ADMIN_GROUP_LABELS: Record<TAdminSearchResultType, MessageDescriptor> = {
|
||||||
|
document: msg`Documents`,
|
||||||
|
user: msg`Users`,
|
||||||
|
organisation: msg`Organisations`,
|
||||||
|
team: msg`Teams`,
|
||||||
|
recipient: msg`Recipients`,
|
||||||
|
subscription: msg`Subscriptions`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
|
||||||
|
document: FileTextIcon,
|
||||||
|
user: UserIcon,
|
||||||
|
organisation: Building2Icon,
|
||||||
|
team: UsersIcon,
|
||||||
|
recipient: UserIcon,
|
||||||
|
subscription: CreditCardIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin list pages which support prefilling their search from the URL, used
|
||||||
|
* for the "View all results" links on capped groups. Teams, recipients and
|
||||||
|
* subscriptions have no admin list pages.
|
||||||
|
*/
|
||||||
|
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
|
||||||
|
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
|
||||||
|
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
|
||||||
|
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAdminSearchCategoriesOptions = {
|
||||||
|
/**
|
||||||
|
* The trimmed, debounced search query.
|
||||||
|
*/
|
||||||
|
query: string;
|
||||||
|
open: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The isolated admin portion of the command prompt: searches every admin
|
||||||
|
* resource and maps the results to prompt categories marked as global.
|
||||||
|
*
|
||||||
|
* Returns no categories and never queries for non admin users. The admin
|
||||||
|
* search endpoint is additionally guarded server side by the admin procedure.
|
||||||
|
*/
|
||||||
|
export const useAdminSearchCategories = ({ query, open }: UseAdminSearchCategoriesOptions) => {
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
|
const isUserAdmin = isAdmin(user);
|
||||||
|
|
||||||
|
// Admin searches hit every resource table, so require a longer query unless
|
||||||
|
// it is a number, which could be a resource ID of any length. Queries over
|
||||||
|
// the endpoint's length limit are skipped entirely instead of being sent
|
||||||
|
// and rejected.
|
||||||
|
const hasValidAdminSearch =
|
||||||
|
isUserAdmin && query.length <= ADMIN_SEARCH_MAX_QUERY_LENGTH && (query.length > 3 || /^\d+$/.test(query));
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: adminSearchData,
|
||||||
|
isFetching,
|
||||||
|
isError,
|
||||||
|
} = trpcReact.admin.search.useQuery(
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: open && hasValidAdminSearch,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
// Retyping is the retry in a search-as-you-type flow: fail fast so the
|
||||||
|
// prompt can surface an honest error state instead of retrying.
|
||||||
|
retry: false,
|
||||||
|
...SKIP_QUERY_BATCH_META,
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const categories = useMemo((): PromptCategory[] => {
|
||||||
|
if (!hasValidAdminSearch || !adminSearchData) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adminSearchData.groups.map((group) => {
|
||||||
|
const isCapped = group.results.length >= ADMIN_SEARCH_RESULTS_CAP;
|
||||||
|
const buildListPath = ADMIN_GROUP_LIST_PATHS[group.type];
|
||||||
|
|
||||||
|
const items: PromptItem[] = group.results.map((result) => ({
|
||||||
|
id: `admin-${group.type}-${result.value}`,
|
||||||
|
label: result.label,
|
||||||
|
sublabel: result.sublabel,
|
||||||
|
path: result.path,
|
||||||
|
icon: ADMIN_GROUP_ICONS[group.type],
|
||||||
|
initials: group.type === 'user' || group.type === 'recipient' ? extractInitials(result.label) : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Capped groups link to the full admin list page with the search
|
||||||
|
// prefilled so the cap is never a dead end.
|
||||||
|
if (isCapped && buildListPath) {
|
||||||
|
items.push({
|
||||||
|
id: `admin-${group.type}-view-all`,
|
||||||
|
label: msg`View all results`,
|
||||||
|
path: buildListPath(query),
|
||||||
|
icon: ArrowRightIcon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `admin-${group.type}`,
|
||||||
|
label: ADMIN_GROUP_LABELS[group.type],
|
||||||
|
items,
|
||||||
|
count: group.results.length,
|
||||||
|
chipCount: group.results.length,
|
||||||
|
isCapped,
|
||||||
|
isGlobal: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [hasValidAdminSearch, adminSearchData, query]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isUserAdmin,
|
||||||
|
categories,
|
||||||
|
isFetching,
|
||||||
|
isError,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -88,7 +88,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
delegateDocumentOwnership: delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||||
reminderSettings: reminderSettings ?? undefined,
|
reminderSettings: reminderSettings ?? undefined,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default function TeamsSettingsPage() {
|
|||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
}),
|
}),
|
||||||
delegateDocumentOwnership: delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/
|
|||||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { Plural } from '@lingui/react/macro';
|
import { Plural } from '@lingui/react/macro';
|
||||||
import { UsersIcon } from 'lucide-react';
|
import { UsersIcon } from 'lucide-react';
|
||||||
@@ -14,6 +15,7 @@ import { redirect } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
|
||||||
|
import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page';
|
||||||
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
|
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
|
||||||
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
|
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
|
||||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||||
@@ -70,8 +72,18 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipientsWithMissingFields = getRecipientsWithMissingFields(template.recipients, template.fields);
|
||||||
|
|
||||||
|
if (recipientsWithMissingFields.length > 0) {
|
||||||
|
return {
|
||||||
|
isAccessAuthValid: true,
|
||||||
|
isTemplateMissingSignatures: true,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAccessAuthValid: true,
|
isAccessAuthValid: true,
|
||||||
|
isTemplateMissingSignatures: false,
|
||||||
template: {
|
template: {
|
||||||
...template,
|
...template,
|
||||||
folder: null,
|
folder: null,
|
||||||
@@ -96,6 +108,7 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||||||
.then((envelopeForSigning) => {
|
.then((envelopeForSigning) => {
|
||||||
return {
|
return {
|
||||||
isDocumentAccessValid: true,
|
isDocumentAccessValid: true,
|
||||||
|
isTemplateMissingSignatures: false,
|
||||||
envelopeForSigning,
|
envelopeForSigning,
|
||||||
} as const;
|
} as const;
|
||||||
})
|
})
|
||||||
@@ -108,6 +121,13 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
|
|||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: true,
|
||||||
|
isTemplateMissingSignatures: true,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Response('Not Found', { status: 404 });
|
throw new Response('Not Found', { status: 404 });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -181,6 +201,10 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
return <DirectTemplateAuthPageView />;
|
return <DirectTemplateAuthPageView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.isTemplateMissingSignatures) {
|
||||||
|
return <DirectTemplateInvalidPageView />;
|
||||||
|
}
|
||||||
|
|
||||||
const { template, directTemplateRecipient } = data;
|
const { template, directTemplateRecipient } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -235,6 +259,10 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
|
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.isTemplateMissingSignatures) {
|
||||||
|
return <DirectTemplateInvalidPageView />;
|
||||||
|
}
|
||||||
|
|
||||||
const { envelope, recipient } = data.envelopeForSigning;
|
const { envelope, recipient } = data.envelopeForSigning;
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
|||||||
@@ -1,98 +1,15 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { redirect } from 'react-router';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
import type { Route } from './+types/organisation.decline.$token';
|
import type { Route } from './+types/organisation.decline.$token';
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export function loader({ params }: Route.LoaderArgs) {
|
||||||
const { token } = params;
|
const { token } = params;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return {
|
throw redirect('/');
|
||||||
state: 'InvalidLink',
|
|
||||||
} as const;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({
|
// Declining now happens on the invite page via tRPC. Redirect there with the
|
||||||
where: {
|
// `action=decline` flag so it renders the decline-only view (no accept).
|
||||||
token,
|
throw redirect(`/organisation/invite/${token}?action=decline`);
|
||||||
},
|
|
||||||
include: {
|
|
||||||
organisation: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!organisationMemberInvite) {
|
|
||||||
return {
|
|
||||||
state: 'InvalidLink',
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (organisationMemberInvite.status !== OrganisationMemberInviteStatus.DECLINED) {
|
|
||||||
await prisma.organisationMemberInvite.update({
|
|
||||||
where: {
|
|
||||||
id: organisationMemberInvite.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: OrganisationMemberInviteStatus.DECLINED,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: 'Success',
|
|
||||||
organisationName: organisationMemberInvite.organisation.name,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeclineInvitationPage({ loaderData }: Route.ComponentProps) {
|
|
||||||
const data = loaderData;
|
|
||||||
|
|
||||||
if (data.state === 'InvalidLink') {
|
|
||||||
return (
|
|
||||||
<div className="w-screen max-w-lg px-4">
|
|
||||||
<div className="w-full">
|
|
||||||
<h1 className="font-semibold text-4xl">
|
|
||||||
<Trans>Invalid token</Trans>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
|
||||||
<Trans>This token is invalid or has expired. No action is needed.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/">
|
|
||||||
<Trans>Return</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-screen max-w-lg px-4">
|
|
||||||
<h1 className="font-semibold text-4xl">
|
|
||||||
<Trans>Invitation declined</Trans>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
|
||||||
<Trans>
|
|
||||||
You have declined the invitation from <strong>{data.organisationName}</strong> to join their organisation.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/">
|
|
||||||
<Trans>Return to Home</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { Link } from 'react-router';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import type { Route } from './+types/organisation.invite.$token';
|
import type { Route } from './+types/organisation.invite.$token';
|
||||||
|
|
||||||
@@ -37,6 +43,22 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const organisationName = organisationMemberInvite.organisation.name;
|
||||||
|
|
||||||
|
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
|
||||||
|
return {
|
||||||
|
state: 'AlreadyAccepted',
|
||||||
|
organisationName,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.DECLINED) {
|
||||||
|
return {
|
||||||
|
state: 'AlreadyDeclined',
|
||||||
|
organisationName,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email: {
|
email: {
|
||||||
@@ -49,26 +71,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Directly convert the team member invite to a team member if they already have an account.
|
|
||||||
if (user) {
|
|
||||||
await acceptOrganisationInvitation({ token: organisationMemberInvite.token });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return {
|
|
||||||
state: 'LoginRequired',
|
|
||||||
email: organisationMemberInvite.email,
|
|
||||||
organisationName: organisationMemberInvite.organisation.name,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: 'Success',
|
state: 'Pending',
|
||||||
|
token: organisationMemberInvite.token,
|
||||||
email: organisationMemberInvite.email,
|
email: organisationMemberInvite.email,
|
||||||
organisationName: organisationMemberInvite.organisation.name,
|
organisationName,
|
||||||
isSessionUserTheInvitedUser,
|
userExists: user !== null,
|
||||||
|
isSessionUserTheInvitedUser: user !== null && user.id === session.user?.id,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,57 +106,253 @@ export default function AcceptInvitationPage({ loaderData }: Route.ComponentProp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.state === 'LoginRequired') {
|
if (data.state === 'AlreadyAccepted') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="w-screen max-w-lg px-4">
|
||||||
<h1 className="font-semibold text-4xl">
|
<div className="w-full">
|
||||||
<Trans>Organisation invitation</Trans>
|
<h1 className="font-semibold text-4xl">
|
||||||
</h1>
|
<Trans>Invitation already accepted</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<p className="mt-2 text-muted-foreground text-sm">
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
You have been invited by <strong>{data.organisationName}</strong> to join their organisation.
|
You are already a member of <strong>{data.organisationName}</strong>.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-1 mb-4 text-muted-foreground text-sm">
|
<Button asChild>
|
||||||
<Trans>To accept this invitation you must create an account.</Trans>
|
<Link to="/">
|
||||||
</p>
|
<Trans>Continue</Trans>
|
||||||
|
</Link>
|
||||||
<Button asChild>
|
</Button>
|
||||||
<Link to={`/signup#email=${encodeURIComponent(data.email)}`}>
|
</div>
|
||||||
<Trans>Create account</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.state === 'AlreadyDeclined') {
|
||||||
|
return <InvitationDeclined organisationName={data.organisationName} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PendingInvitation
|
||||||
<h1 className="font-semibold text-4xl">
|
token={data.token}
|
||||||
<Trans>Invitation accepted!</Trans>
|
email={data.email}
|
||||||
</h1>
|
organisationName={data.organisationName}
|
||||||
|
userExists={data.userExists}
|
||||||
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
isSessionUserTheInvitedUser={data.isSessionUserTheInvitedUser}
|
||||||
<Trans>
|
/>
|
||||||
You have accepted an invitation from <strong>{data.organisationName}</strong> to join their organisation.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{data.isSessionUserTheInvitedUser ? (
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/">
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button asChild>
|
|
||||||
<Link to={`/signin#email=${encodeURIComponent(data.email)}`}>
|
|
||||||
<Trans>Continue to login</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingInvitationProps = {
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
organisationName: string;
|
||||||
|
userExists: boolean;
|
||||||
|
isSessionUserTheInvitedUser: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InvitationResult = 'idle' | 'accepted' | 'declined';
|
||||||
|
|
||||||
|
type AcceptFailureReason = 'CapExceeded' | 'SubscriptionInactive' | 'Unknown';
|
||||||
|
|
||||||
|
const PendingInvitation = ({
|
||||||
|
token,
|
||||||
|
email,
|
||||||
|
organisationName,
|
||||||
|
userExists,
|
||||||
|
isSessionUserTheInvitedUser,
|
||||||
|
}: PendingInvitationProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { refreshSession } = useOptionalSession();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const actionIsDecline = searchParams.get('action') === 'decline';
|
||||||
|
|
||||||
|
const [result, setResult] = useState<InvitationResult>('idle');
|
||||||
|
const [acceptFailureReason, setAcceptFailureReason] = useState<AcceptFailureReason | null>(null);
|
||||||
|
|
||||||
|
const acceptInvitation = trpc.organisation.member.invite.accept.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
|
setResult('accepted');
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const failureReason = match(error.code)
|
||||||
|
.with(AppErrorCode.LIMIT_EXCEEDED, () => 'CapExceeded' as const)
|
||||||
|
.with('SUBSCRIPTION_INACTIVE', () => 'SubscriptionInactive' as const)
|
||||||
|
.otherwise(() => 'Unknown' as const);
|
||||||
|
|
||||||
|
setAcceptFailureReason(failureReason);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const declineInvitation = trpc.organisation.member.invite.decline.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await refreshSession();
|
||||||
|
|
||||||
|
setResult('declined');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`Unable to decline this invitation at this time.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result === 'accepted') {
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Invitation accepted!</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have accepted an invitation from <strong>{organisationName}</strong> to join their organisation.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isSessionUserTheInvitedUser ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/">
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/signin#email=${encodeURIComponent(email)}`}>
|
||||||
|
<Trans>Continue to login</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 'declined') {
|
||||||
|
return <InvitationDeclined organisationName={organisationName} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepting requires an account (acceptance keys off the invited email).
|
||||||
|
// Declining does not, so we only gate account creation on the accept flow.
|
||||||
|
if (!actionIsDecline && !userExists) {
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Organisation invitation</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have been invited by <strong>{organisationName}</strong> to join their organisation.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>To accept this invitation you must create an account.</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link to={`/signup#email=${encodeURIComponent(email)}`}>
|
||||||
|
<Trans>Create account</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPending = acceptInvitation.isPending || declineInvitation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Organisation invitation</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have been invited to join <strong>{organisationName}</strong> on Documenso.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{acceptFailureReason && (
|
||||||
|
<p className="mt-2 mb-4 text-destructive text-sm">
|
||||||
|
{match(acceptFailureReason)
|
||||||
|
.with('CapExceeded', () => (
|
||||||
|
<Trans>
|
||||||
|
<strong>{organisationName}</strong> has reached its member limit. Please contact the organisation
|
||||||
|
administrator to upgrade their plan before accepting this invitation.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with('SubscriptionInactive', () => (
|
||||||
|
<Trans>
|
||||||
|
<strong>{organisationName}</strong> does not have an active subscription. Please contact the
|
||||||
|
organisation administrator to renew their plan before accepting this invitation.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.with('Unknown', () => (
|
||||||
|
<Trans>
|
||||||
|
We were unable to add you to <strong>{organisationName}</strong> at this time. Please try again later,
|
||||||
|
or contact the organisation administrator.
|
||||||
|
</Trans>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => declineInvitation.mutateAsync({ token })}
|
||||||
|
loading={declineInvitation.isPending}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Trans>Decline</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!actionIsDecline && (
|
||||||
|
<Button
|
||||||
|
onClick={async () => acceptInvitation.mutateAsync({ token })}
|
||||||
|
loading={acceptInvitation.isPending}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<Trans>Accept</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InvitationDeclined = ({ organisationName }: { organisationName: string }) => {
|
||||||
|
return (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="font-semibold text-4xl">
|
||||||
|
<Trans>Invitation declined</Trans>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 mb-4 text-muted-foreground text-sm">
|
||||||
|
<Trans>
|
||||||
|
You have declined the invitation from <strong>{organisationName}</strong> to join their organisation.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
|
|||||||
return match(code)
|
return match(code)
|
||||||
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||||
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
|
||||||
|
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
|
||||||
|
title: msg`Missing signature fields`,
|
||||||
|
description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`,
|
||||||
|
}))
|
||||||
.otherwise(() => ({
|
.otherwise(() => ({
|
||||||
title: msg`Something went wrong`,
|
title: msg`Something went wrong`,
|
||||||
description: msg`We were unable to submit this document at this time. Please try again later.`,
|
description: msg`We were unable to submit this document at this time. Please try again later.`,
|
||||||
@@ -77,6 +81,10 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor
|
|||||||
title: msg`Error`,
|
title: msg`Error`,
|
||||||
description: msg`The document was created but could not be sent to recipients.`,
|
description: msg`The document was created but could not be sent to recipients.`,
|
||||||
}))
|
}))
|
||||||
|
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
|
||||||
|
title: msg`Missing signature fields`,
|
||||||
|
description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`,
|
||||||
|
}))
|
||||||
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
|
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
|
||||||
title: msg`Error`,
|
title: msg`Error`,
|
||||||
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
|
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
|
||||||
|
|||||||
@@ -106,5 +106,5 @@
|
|||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"version": "2.15.0"
|
"version": "2.16.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "2.15.0",
|
"version": "2.16.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "2.15.0",
|
"version": "2.16.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
},
|
},
|
||||||
"apps/remix": {
|
"apps/remix": {
|
||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
"version": "2.15.0",
|
"version": "2.16.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cantoo/pdf-lib": "^2.5.3",
|
"@cantoo/pdf-lib": "^2.5.3",
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "2.15.0",
|
"version": "2.16.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const OpenAPIV1 = Object.assign(
|
|||||||
title: 'Documenso API',
|
title: 'Documenso API',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description:
|
description:
|
||||||
'API V1 is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
|
'API V1 has been deprecated. For more details, see https://docs.documenso.com/docs/developers/api/migrate-to-envelopes. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
|
||||||
},
|
},
|
||||||
servers: [
|
servers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { customAlphabet } from 'nanoid';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import { openCommandMenu } from '../fixtures/command-menu';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||||
|
|
||||||
|
const ADMIN_PROMPT_PLACEHOLDER = 'Search documents, users, organisations…';
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified user result and navigates', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: targetUser } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetUser.id));
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// The category chips include the admin groups with their result counts.
|
||||||
|
await expect(page.getByRole('button', { name: /Global Users/ })).toBeVisible();
|
||||||
|
|
||||||
|
const userOption = page.getByRole('option').filter({ hasText: targetUser.email }).first();
|
||||||
|
|
||||||
|
// Admin results are real links so they support native link behaviour such
|
||||||
|
// as opening in a new tab.
|
||||||
|
await expect(userOption.getByRole('link')).toHaveAttribute('href', `/admin/users/${targetUser.id}`);
|
||||||
|
|
||||||
|
await userOption.click();
|
||||||
|
|
||||||
|
await page.waitForURL(`/admin/users/${targetUser.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: numeric query shows verified team result and navigates', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { team: targetTeam } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(String(targetTeam.id));
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Teams', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('option').filter({ hasText: targetTeam.url }).first().click();
|
||||||
|
|
||||||
|
await page.waitForURL(`/admin/teams/${targetTeam.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: text query shows document result and navigates', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, team } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(sender, team.id, [], {
|
||||||
|
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('option').filter({ hasText: document.secondaryId }).first().click();
|
||||||
|
|
||||||
|
await page.waitForURL(`/admin/documents/${document.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: envelope_ prefixed query resolves exact document', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, team } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(sender, team.id, [], {
|
||||||
|
createDocumentOptions: { title: `admin-ui-search-${nanoid()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.id);
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Documents', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: document.title }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: admin search requires more than 3 characters unless numeric', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
const adminSearchRequests: string[] = [];
|
||||||
|
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('admin.search')) {
|
||||||
|
adminSearchRequests.push(request.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
|
||||||
|
|
||||||
|
// A 3 character non-numeric query must not trigger the admin search. The
|
||||||
|
// personal document search fires for any non-empty query, so its response
|
||||||
|
// is the synchronization anchor proving the debounced queries have fired.
|
||||||
|
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
|
||||||
|
|
||||||
|
await input.fill('abc');
|
||||||
|
|
||||||
|
await documentSearchResponse;
|
||||||
|
|
||||||
|
await expect(page.getByText(/^Global /)).toHaveCount(0);
|
||||||
|
expect(adminSearchRequests).toHaveLength(0);
|
||||||
|
|
||||||
|
// A numeric query fires regardless of length.
|
||||||
|
const adminSearchRequest = page.waitForRequest((request) => request.url().includes('admin.search'));
|
||||||
|
|
||||||
|
await input.fill('7');
|
||||||
|
|
||||||
|
await adminSearchRequest;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: search bar position stays fixed while searching', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: targetUser } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
|
||||||
|
|
||||||
|
const initialY = (await input.boundingBox())?.y;
|
||||||
|
|
||||||
|
expect(initialY).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The height of the prompt may change as results come and go, but the
|
||||||
|
// search bar must never move.
|
||||||
|
await input.fill(String(targetUser.id));
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const resultsY = (await input.boundingBox())?.y;
|
||||||
|
|
||||||
|
expect(resultsY).toBe(initialY);
|
||||||
|
|
||||||
|
// The search bar must not move when there are no results at all.
|
||||||
|
await input.fill('zzzz-no-such-thing-9x7q');
|
||||||
|
|
||||||
|
await expect(page.getByText('No results for')).toBeVisible();
|
||||||
|
|
||||||
|
const emptyY = (await input.boundingBox())?.y;
|
||||||
|
|
||||||
|
expect(emptyY).toBe(initialY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: default view shows the document page links outside a team context', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
// Admin pages have no current team, the page links must still show.
|
||||||
|
await page.goto('/admin/stats');
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'All documents' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'All templates' })).toBeVisible();
|
||||||
|
|
||||||
|
// Chips only show for categories with actual results, not for the
|
||||||
|
// hardcoded page links.
|
||||||
|
await expect(page.getByRole('button', { name: /^Documents/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: /^Templates/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: theme can be changed from the prompt', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
|
||||||
|
|
||||||
|
// The sub page has a contextual placeholder and a back option.
|
||||||
|
await expect(page.getByPlaceholder('Search themes…')).toBeVisible();
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Back' }).first()).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('option').filter({ hasText: 'Dark Mode' }).first().click();
|
||||||
|
|
||||||
|
await expect(page.locator('html')).toHaveClass(/dark/);
|
||||||
|
|
||||||
|
// The back option returns to the root view.
|
||||||
|
await page.getByRole('option').filter({ hasText: 'Back' }).first().click();
|
||||||
|
|
||||||
|
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: capped admin groups offer a view all link', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
const namePrefix = `viewall-${nanoid()}`;
|
||||||
|
|
||||||
|
// Seed enough users sharing a name prefix to hit the 5 result cap.
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await seedUser({ name: `${namePrefix}-${i}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(namePrefix);
|
||||||
|
|
||||||
|
await expect(page.getByText('Global Users', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
|
||||||
|
|
||||||
|
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
`/admin/users?search=${encodeURIComponent(namePrefix)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: first result is highlighted after every search', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: firstUser } = await seedUser();
|
||||||
|
const { user: secondUser } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
const input = page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first();
|
||||||
|
|
||||||
|
// First search selects the first result.
|
||||||
|
await input.fill(String(firstUser.id));
|
||||||
|
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: firstUser.email }).first()).toBeVisible();
|
||||||
|
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
// A subsequent search with entirely new results must select the first
|
||||||
|
// result again.
|
||||||
|
await input.fill(String(secondUser.id));
|
||||||
|
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: secondUser.email }).first()).toBeVisible();
|
||||||
|
await expect(page.locator('[cmdk-item]').first()).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: static items match fuzzy queries', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
// "setg" is a non-contiguous abbreviation of "Settings".
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('setg');
|
||||||
|
|
||||||
|
// Wait for the debounced filter to apply first, "Draft documents" can
|
||||||
|
// never match "setg" under either matching strategy.
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Draft documents' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Settings' }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: page scrollbar is hidden while the prompt is open', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
|
||||||
|
.toBe('hidden');
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await page.evaluate(() => getComputedStyle(document.documentElement).overflow))
|
||||||
|
.toBe('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: non-admin gets the prompt without the admin search', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser({ isAdmin: false });
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(user, team.id, []);
|
||||||
|
|
||||||
|
const adminSearchRequests: string[] = [];
|
||||||
|
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('admin.search')) {
|
||||||
|
adminSearchRequests.push(request.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: user.email });
|
||||||
|
|
||||||
|
// Non-admins get the same prompt with a non-admin placeholder.
|
||||||
|
await openCommandMenu(page, 'Type a command or search...');
|
||||||
|
|
||||||
|
await expect(page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER)).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
||||||
|
|
||||||
|
// Wait for the regular (non-admin) search to resolve so we know the
|
||||||
|
// debounced queries have fired.
|
||||||
|
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByText(/^Global /)).toHaveCount(0);
|
||||||
|
expect(adminSearchRequests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: typing on a sub page fires no search requests', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
const searchRequests: string[] = [];
|
||||||
|
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (/api\/trpc\/(document|template|admin)\.search/.test(request.url())) {
|
||||||
|
searchRequests.push(request.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByRole('option').filter({ hasText: 'Change theme' }).first().click();
|
||||||
|
|
||||||
|
const input = page.getByPlaceholder('Search themes…');
|
||||||
|
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
|
// Long enough to pass the admin search threshold if it were enabled.
|
||||||
|
await input.fill('dark');
|
||||||
|
|
||||||
|
// The client-side filter applying proves the typing registered.
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Dark Mode' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option').filter({ hasText: 'Light Mode' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Wait out the 200ms search debounce with a wide margin before asserting
|
||||||
|
// that no requests fired: there is no response to anchor on when the
|
||||||
|
// desired behaviour is "no requests at all".
|
||||||
|
await page.waitForTimeout(750);
|
||||||
|
|
||||||
|
expect(searchRequests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: failed searches show an error state instead of no results', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await page.route(/api\/trpc\/(document|template|admin)\.search/, async (route) => {
|
||||||
|
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('zzzz-no-such-thing-9x7q');
|
||||||
|
|
||||||
|
// A failed search must be honest about it, not claim there are no results.
|
||||||
|
await expect(page.getByText('Something went wrong')).toBeVisible();
|
||||||
|
await expect(page.getByText('No results for')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: partial search failure still shows results with a notice', async ({ page }) => {
|
||||||
|
const { user: adminUser, team } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(adminUser, team.id, [], {
|
||||||
|
createDocumentOptions: { title: `partial-fail-${nanoid()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only the admin search fails: the personal searches succeed.
|
||||||
|
await page.route(/api\/trpc\/admin\.search/, async (route) => {
|
||||||
|
await route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(document.title);
|
||||||
|
|
||||||
|
// The successful personal document search must still render its results.
|
||||||
|
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||||
|
|
||||||
|
// The failed admin search must be flagged rather than silently dropped.
|
||||||
|
await expect(page.getByText('Some searches failed')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without erroring', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
const adminSearchRequests: string[] = [];
|
||||||
|
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.url().includes('admin.search')) {
|
||||||
|
adminSearchRequests.push(request.url());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||||
|
|
||||||
|
// The admin search endpoint rejects queries longer than 100 characters, so
|
||||||
|
// the client must not send them. The personal searches accept up to 1024
|
||||||
|
// characters and still run, anchoring the debounced query flush.
|
||||||
|
const documentSearchResponse = page.waitForResponse((response) => response.url().includes('document.search'));
|
||||||
|
|
||||||
|
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill('a'.repeat(150));
|
||||||
|
|
||||||
|
await documentSearchResponse;
|
||||||
|
|
||||||
|
// The personal searches ran and found nothing: the honest empty state, with
|
||||||
|
// no error in sight.
|
||||||
|
await expect(page.getByText('No results for')).toBeVisible();
|
||||||
|
await expect(page.getByText('Something went wrong')).toHaveCount(0);
|
||||||
|
|
||||||
|
expect(adminSearchRequests).toHaveLength(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { customAlphabet } from 'nanoid';
|
||||||
|
|
||||||
|
import { apiSignin } from '../../../fixtures/authentication';
|
||||||
|
|
||||||
|
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||||
|
|
||||||
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
type AdminSearchGroup = {
|
||||||
|
type: string;
|
||||||
|
results: Array<{ label: string; sublabel?: string; path: string; value: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const callAdminSearch = async (page: Page, query: string) => {
|
||||||
|
const inputParam = encodeURIComponent(JSON.stringify({ json: { query } }));
|
||||||
|
const url = `${WEBAPP_BASE_URL}/api/trpc/admin.search?input=${inputParam}`;
|
||||||
|
|
||||||
|
const res = await page.context().request.get(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
res,
|
||||||
|
groups: res.ok()
|
||||||
|
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
((await res.json()).result.data.json.groups as AdminSearchGroup[])
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const findGroup = (groups: AdminSearchGroup[] | null, type: string) =>
|
||||||
|
(groups ?? []).find((group) => group.type === type);
|
||||||
|
|
||||||
|
// ─── Access control ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: unauthenticated request is rejected with 401', async ({ page }) => {
|
||||||
|
const { res } = await callAdminSearch(page, 'anything');
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: non-admin authenticated user is rejected with 401', async ({ page }) => {
|
||||||
|
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: nonAdminUser.email });
|
||||||
|
|
||||||
|
const { res } = await callAdminSearch(page, 'anything');
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Numeric queries: verified ID lookups ────────────────────────────────────
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified user and team rows', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: targetUser, team: targetTeam } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
// Search by user ID.
|
||||||
|
const userSearch = await callAdminSearch(page, String(targetUser.id));
|
||||||
|
|
||||||
|
expect(userSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const userGroup = findGroup(userSearch.groups, 'user');
|
||||||
|
expect(userGroup).toBeDefined();
|
||||||
|
expect(userGroup?.results).toHaveLength(1);
|
||||||
|
expect(userGroup?.results[0].path).toBe(`/admin/users/${targetUser.id}`);
|
||||||
|
expect(userGroup?.results[0].sublabel).toContain(targetUser.email);
|
||||||
|
|
||||||
|
// The cmdk `value` contract: value must contain the raw query.
|
||||||
|
expect(userGroup?.results[0].value).toContain(String(targetUser.id));
|
||||||
|
|
||||||
|
// Search by team ID.
|
||||||
|
const teamSearch = await callAdminSearch(page, String(targetTeam.id));
|
||||||
|
|
||||||
|
expect(teamSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const teamGroup = findGroup(teamSearch.groups, 'team');
|
||||||
|
expect(teamGroup).toBeDefined();
|
||||||
|
expect(teamGroup?.results).toHaveLength(1);
|
||||||
|
expect(teamGroup?.results[0].path).toBe(`/admin/teams/${targetTeam.id}`);
|
||||||
|
expect(teamGroup?.results[0].label).toBe(targetTeam.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: numeric query returns verified document and recipient rows', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, team } = await seedUser();
|
||||||
|
const { user: recipientUser } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(sender, team.id, [recipientUser]);
|
||||||
|
const legacyDocumentId = document.secondaryId.replace('document_', '');
|
||||||
|
const recipient = document.recipients[0];
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
// Search by legacy document ID (bare number).
|
||||||
|
const documentSearch = await callAdminSearch(page, legacyDocumentId);
|
||||||
|
|
||||||
|
expect(documentSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const documentGroup = findGroup(documentSearch.groups, 'document');
|
||||||
|
expect(documentGroup).toBeDefined();
|
||||||
|
expect(documentGroup?.results).toHaveLength(1);
|
||||||
|
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||||
|
expect(documentGroup?.results[0].label).toBe(document.title);
|
||||||
|
|
||||||
|
// Search by recipient ID: links to the parent document.
|
||||||
|
const recipientSearch = await callAdminSearch(page, String(recipient.id));
|
||||||
|
|
||||||
|
expect(recipientSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const recipientGroup = findGroup(recipientSearch.groups, 'recipient');
|
||||||
|
expect(recipientGroup).toBeDefined();
|
||||||
|
expect(recipientGroup?.results).toHaveLength(1);
|
||||||
|
expect(recipientGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||||
|
expect(recipientGroup?.results[0].label).toBe(recipient.email);
|
||||||
|
expect(recipientGroup?.results[0].sublabel).toBe(`#${recipient.id} · ${recipient.name} · ${document.title}`);
|
||||||
|
|
||||||
|
// Search by the full document_<id> secondary ID: exercises the prefix branch.
|
||||||
|
const secondaryIdSearch = await callAdminSearch(page, document.secondaryId);
|
||||||
|
|
||||||
|
expect(secondaryIdSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const secondaryIdGroup = findGroup(secondaryIdSearch.groups, 'document');
|
||||||
|
expect(secondaryIdGroup).toBeDefined();
|
||||||
|
expect(secondaryIdGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: numeric query with no matches returns no groups', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
const { res, groups } = await callAdminSearch(page, '999999999');
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
expect(groups).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: oversized number does not error and falls back to text search', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, team } = await seedUser();
|
||||||
|
|
||||||
|
// 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be
|
||||||
|
// treated as text (and must not 500).
|
||||||
|
const oversizedNumber = '99999999999999';
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(sender, team.id, [], {
|
||||||
|
createDocumentOptions: { title: `${oversizedNumber}-${nanoid()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
const { res, groups } = await callAdminSearch(page, oversizedNumber);
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const documentGroup = findGroup(groups, 'document');
|
||||||
|
expect(documentGroup).toBeDefined();
|
||||||
|
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Prefixed ID queries: exact lookups ──────────────────────────────────────
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: envelope_ and org_ prefixes resolve exact matches', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, organisation, team } = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(sender, team.id, []);
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
// envelope_<id> resolves the document.
|
||||||
|
const envelopeSearch = await callAdminSearch(page, document.id);
|
||||||
|
|
||||||
|
expect(envelopeSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const documentGroup = findGroup(envelopeSearch.groups, 'document');
|
||||||
|
expect(documentGroup).toBeDefined();
|
||||||
|
expect(documentGroup?.results[0].path).toBe(`/admin/documents/${document.id}`);
|
||||||
|
|
||||||
|
// Only the document group is returned for a recognized prefix.
|
||||||
|
expect(envelopeSearch.groups).toHaveLength(1);
|
||||||
|
|
||||||
|
// org_<id> resolves the organisation.
|
||||||
|
const orgSearch = await callAdminSearch(page, organisation.id);
|
||||||
|
|
||||||
|
expect(orgSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const orgGroup = findGroup(orgSearch.groups, 'organisation');
|
||||||
|
expect(orgGroup).toBeDefined();
|
||||||
|
expect(orgGroup?.results[0].path).toBe(`/admin/organisations/${organisation.id}`);
|
||||||
|
expect(orgGroup?.results[0].label).toBe(organisation.name);
|
||||||
|
|
||||||
|
// Only the organisation group is returned for a recognized prefix.
|
||||||
|
expect(orgSearch.groups).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Free text queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: text query matches documents by title and users by email', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
const { user: sender, team } = await seedUser();
|
||||||
|
|
||||||
|
// A unique title: the default seeded title is shared across the whole suite,
|
||||||
|
// and global search only returns the newest few matches.
|
||||||
|
const document = await seedPendingDocument(sender, team.id, [], {
|
||||||
|
createDocumentOptions: { title: `admin-search-${nanoid()}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
// Search by document title.
|
||||||
|
const titleSearch = await callAdminSearch(page, document.title);
|
||||||
|
|
||||||
|
expect(titleSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const documentGroup = findGroup(titleSearch.groups, 'document');
|
||||||
|
expect(documentGroup).toBeDefined();
|
||||||
|
expect(documentGroup?.results.map((result) => result.path)).toContain(`/admin/documents/${document.id}`);
|
||||||
|
|
||||||
|
// Search by user email (emails are unique nanoid-based, so this is specific).
|
||||||
|
const emailSearch = await callAdminSearch(page, sender.email);
|
||||||
|
|
||||||
|
expect(emailSearch.res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const userGroup = findGroup(emailSearch.groups, 'user');
|
||||||
|
expect(userGroup).toBeDefined();
|
||||||
|
expect(userGroup?.results[0].path).toBe(`/admin/users/${sender.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN][TRPC][SEARCH]: gibberish query returns no groups', async ({ page }) => {
|
||||||
|
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||||
|
|
||||||
|
await apiSignin({ page, email: adminUser.email });
|
||||||
|
|
||||||
|
const { res, groups } = await callAdminSearch(page, 'zzzz-no-such-thing-9x7q');
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
expect(groups).toEqual([]);
|
||||||
|
});
|
||||||
@@ -50,7 +50,7 @@ import type { Organisation, Team, User } from '@prisma/client';
|
|||||||
*
|
*
|
||||||
* --- GLOBAL LIMIT AWARENESS ---
|
* --- GLOBAL LIMIT AWARENESS ---
|
||||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
|
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
|
||||||
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
* apiV1RateLimit = 1000 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||||
@@ -62,7 +62,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
|||||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
|
||||||
|
|
||||||
// Run serially: all workers share one IP, and the global /api/v1 limiter is
|
// Run serially: all workers share one IP, and the global /api/v1 limiter is
|
||||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||||
@@ -125,7 +125,7 @@ const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
|||||||
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
|
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
|
||||||
*
|
*
|
||||||
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
|
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 1000/min
|
||||||
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
|
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
|
||||||
* Across the suite (and especially across repeated local runs within the same
|
* Across the suite (and especially across repeated local runs within the same
|
||||||
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import type { Organisation, Team, User } from '@prisma/client';
|
|||||||
*
|
*
|
||||||
* --- GLOBAL LIMIT AWARENESS ---
|
* --- GLOBAL LIMIT AWARENESS ---
|
||||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
|
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
|
||||||
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
|
* apiV2RateLimit = 1000 requests / 1 minute (see rate-limits.ts).
|
||||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||||
@@ -49,7 +49,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
|||||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||||
|
|
||||||
// Run serially: all workers share one IP, and the global /api/v2 limiter is
|
// Run serially: all workers share one IP, and the global /api/v2 limiter is
|
||||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import { openCommandMenu } from '../fixtures/command-menu';
|
||||||
|
|
||||||
|
const COMMAND_MENU_PLACEHOLDER = 'Type a command or search...';
|
||||||
|
|
||||||
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
|
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
|
||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
@@ -14,9 +17,9 @@ test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.keyboard.press('Meta+K');
|
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||||
|
|
||||||
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
|
||||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,9 +33,9 @@ test('[COMMAND_MENU]: should see received documents', async ({ page }) => {
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.keyboard.press('Meta+K');
|
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||||
|
|
||||||
await page.getByPlaceholder('Type a command or search...').first().fill(document.title);
|
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(document.title);
|
||||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,8 +49,8 @@ test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) =
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.keyboard.press('Meta+K');
|
await openCommandMenu(page, COMMAND_MENU_PLACEHOLDER);
|
||||||
|
|
||||||
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
|
await page.getByPlaceholder(COMMAND_MENU_PLACEHOLDER).first().fill(recipient.email);
|
||||||
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
await expect(page.getByRole('option', { name: document.title })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, type Page, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor';
|
||||||
|
|
||||||
|
const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place a field on the PDF canvas in the envelope editor.
|
||||||
|
*/
|
||||||
|
const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => {
|
||||||
|
await root.getByRole('button', { name: fieldName, exact: true }).click();
|
||||||
|
|
||||||
|
const canvas = root.locator('.konva-container canvas').first();
|
||||||
|
await expect(canvas).toBeVisible();
|
||||||
|
await canvas.click({ position });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a V2 direct template and open it in the native template editor.
|
||||||
|
*
|
||||||
|
* Only the native template editor is covered here: direct links only exist
|
||||||
|
* for templates and are not part of the embedded editor surfaces.
|
||||||
|
*/
|
||||||
|
const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const template = await seedDirectTemplate({
|
||||||
|
title: `E2E Direct Template Validation ${Date.now()}`,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
internalVersion: 2,
|
||||||
|
createDirectRecipientSignatureField: options.createDirectRecipientSignatureField,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user, team, template };
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('template editor', () => {
|
||||||
|
test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => {
|
||||||
|
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
|
||||||
|
|
||||||
|
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
|
||||||
|
await expect(page.getByText('are missing a signature field')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not show the warning when all signers have signature fields', async ({ page }) => {
|
||||||
|
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true });
|
||||||
|
|
||||||
|
// Wait for the editor to render before asserting the banner is absent.
|
||||||
|
await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible();
|
||||||
|
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warning disappears after placing a signature field', async ({ page }) => {
|
||||||
|
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
|
||||||
|
|
||||||
|
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
|
||||||
|
|
||||||
|
// Place a signature field for the direct recipient (auto-selected single recipient).
|
||||||
|
await clickEnvelopeEditorStep(page, 'addFields');
|
||||||
|
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
|
||||||
|
await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 });
|
||||||
|
|
||||||
|
// The banner clears once the field is autosaved and the envelope state updates.
|
||||||
|
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the app command menu via the keyboard shortcut.
|
||||||
|
*
|
||||||
|
* Retries the shortcut until the menu appears since the keypress is a no-op
|
||||||
|
* when it happens before the page has hydrated.
|
||||||
|
*
|
||||||
|
* @param placeholder The search input placeholder to wait for, which differs
|
||||||
|
* between admin and non-admin users.
|
||||||
|
*/
|
||||||
|
export const openCommandMenu = async (page: Page, placeholder: string) => {
|
||||||
|
await expect(async () => {
|
||||||
|
await page.keyboard.press('Meta+K');
|
||||||
|
await expect(page.getByPlaceholder(placeholder).first()).toBeVisible({ timeout: 1_000 });
|
||||||
|
}).toPass({ timeout: 15_000 });
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||||
|
import { signSignaturePad } from '../fixtures/signature';
|
||||||
|
|
||||||
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
@@ -73,8 +74,19 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
|||||||
await expect(page.locator('body')).toContainText('public-direct-template-title');
|
await expect(page.locator('body')).toContainText('public-direct-template-title');
|
||||||
await expect(page.locator('body')).toContainText('public-direct-template-description');
|
await expect(page.locator('body')).toContainText('public-direct-template-description');
|
||||||
|
|
||||||
|
const directSignatureField = directTemplate.fields[0];
|
||||||
|
|
||||||
|
if (!directSignatureField) {
|
||||||
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
|
}
|
||||||
|
|
||||||
await page.getByRole('link', { name: 'Sign' }).click();
|
await page.getByRole('link', { name: 'Sign' }).click();
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||||
|
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
await page.getByRole('button', { name: 'Sign' }).click();
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,18 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
|
|||||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
await expect(page.getByLabel('Email')).toBeDisabled();
|
await expect(page.getByLabel('Email')).toBeDisabled();
|
||||||
|
|
||||||
|
const directSignatureField = directTemplateWithAuth.fields[0];
|
||||||
|
|
||||||
|
if (!directSignatureField) {
|
||||||
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
|
}
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||||
|
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Sign' }).click();
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
@@ -235,6 +246,37 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
|
|||||||
await page.goto(directTemplatePath);
|
await page.goto(directTemplatePath);
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
|
||||||
|
|
||||||
|
const directSignatureField = directTemplateWithAuth.fields[0];
|
||||||
|
|
||||||
|
if (!directSignatureField) {
|
||||||
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the PDF and the Konva canvas overlay to be ready.
|
||||||
|
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
const canvas = page.locator('.konva-container canvas').first();
|
||||||
|
await expect(canvas).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Sign the direct template recipient's signature field via the canvas-based V2 UI.
|
||||||
|
await signSignaturePad(page);
|
||||||
|
|
||||||
|
const canvasBox = await canvas.boundingBox();
|
||||||
|
|
||||||
|
if (!canvasBox) {
|
||||||
|
throw new Error('Canvas bounding box not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const x =
|
||||||
|
(Number(directSignatureField.positionX) / 100) * canvasBox.width +
|
||||||
|
((Number(directSignatureField.width) / 100) * canvasBox.width) / 2;
|
||||||
|
const y =
|
||||||
|
(Number(directSignatureField.positionY) / 100) * canvasBox.height +
|
||||||
|
((Number(directSignatureField.height) / 100) * canvasBox.height) / 2;
|
||||||
|
|
||||||
|
await canvas.click({ position: { x, y } });
|
||||||
|
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
await expect(page.getByLabel('Your Email')).not.toBeVisible();
|
await expect(page.getByLabel('Your Email')).not.toBeVisible();
|
||||||
|
|
||||||
@@ -266,6 +308,16 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
|
|||||||
|
|
||||||
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
|
||||||
|
|
||||||
|
const directSignatureField = template.fields[0];
|
||||||
|
|
||||||
|
if (!directSignatureField) {
|
||||||
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
await signSignaturePad(page);
|
||||||
|
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
|
||||||
|
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Complete' }).click();
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
await page.getByRole('button', { name: 'Sign' }).click();
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
await page.waitForURL(/\/sign/);
|
await page.waitForURL(/\/sign/);
|
||||||
@@ -299,19 +351,13 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const directTemplateRecipient = template.recipients[0];
|
// The seeded direct template already includes a signature field for the direct recipient.
|
||||||
|
const directSignatureField = template.fields[0];
|
||||||
|
|
||||||
if (!directTemplateRecipient) {
|
if (!directSignatureField) {
|
||||||
throw new Error('Expected direct template recipient to exist');
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
}
|
}
|
||||||
|
|
||||||
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
|
|
||||||
const directSignatureField = await seedSignatureFieldForRecipient({
|
|
||||||
envelopeId: template.id,
|
|
||||||
recipientId: directTemplateRecipient.id,
|
|
||||||
positionY: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
const originalName = 'Signer 2';
|
const originalName = 'Signer 2';
|
||||||
const originalSecondSignerEmail = seedTestEmail();
|
const originalSecondSignerEmail = seedTestEmail();
|
||||||
|
|
||||||
@@ -413,19 +459,13 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const directTemplateRecipient = template.recipients[0];
|
// The seeded direct template already includes a signature field for the direct recipient.
|
||||||
|
const directSignatureField = template.fields[0];
|
||||||
|
|
||||||
if (!directTemplateRecipient) {
|
if (!directSignatureField) {
|
||||||
throw new Error('Expected direct template recipient to exist');
|
throw new Error('Expected seeded direct template signature field to exist');
|
||||||
}
|
}
|
||||||
|
|
||||||
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
|
|
||||||
const directSignatureField = await seedSignatureFieldForRecipient({
|
|
||||||
envelopeId: template.id,
|
|
||||||
recipientId: directTemplateRecipient.id,
|
|
||||||
positionY: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
const originalName = 'Signer 2';
|
const originalName = 'Signer 2';
|
||||||
const originalSecondSignerEmail = seedTestEmail();
|
const originalSecondSignerEmail = seedTestEmail();
|
||||||
|
|
||||||
@@ -521,3 +561,48 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
|
|||||||
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
|
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
|
||||||
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
|
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const template = await seedDirectTemplate({
|
||||||
|
title: 'V1 invalid direct template',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
createDirectRecipientSignatureField: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
|
||||||
|
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
|
||||||
|
|
||||||
|
// The signing flow must not render.
|
||||||
|
await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const template = await seedDirectTemplate({
|
||||||
|
title: 'V2 invalid direct template',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
internalVersion: 2,
|
||||||
|
createDirectRecipientSignatureField: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
|
||||||
|
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
|
||||||
|
|
||||||
|
// The signing flow (PDF canvas) must not render.
|
||||||
|
await expect(page.locator('.konva-container canvas')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||||
|
|
||||||
|
const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: number }) => {
|
||||||
|
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||||
|
where: { envelopeId: options.envelopeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
envelopeId: options.envelopeId,
|
||||||
|
envelopeItemId: envelopeItem.id,
|
||||||
|
recipientId: options.recipientId,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 5,
|
||||||
|
positionY: 10,
|
||||||
|
width: 20,
|
||||||
|
height: 5,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
// seedTemplate creates one SIGNER recipient and no fields.
|
||||||
|
await seedTemplate({
|
||||||
|
title: 'Template missing signature fields',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
|
||||||
|
|
||||||
|
// Enable distribution so the document is sent on creation.
|
||||||
|
await page.locator('#distributeDocument').click();
|
||||||
|
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||||
|
|
||||||
|
await expectToastTextToBeVisible(page, 'Missing signature fields');
|
||||||
|
await expectToastTextToBeVisible(
|
||||||
|
page,
|
||||||
|
'The document could not be sent because some signers do not have a signature field',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
const template = await seedTemplate({
|
||||||
|
title: 'Template with signature fields',
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedSignatureFieldForRecipient({
|
||||||
|
envelopeId: template.id,
|
||||||
|
recipientId: template.recipients[0].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('#distributeDocument').click();
|
||||||
|
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||||
|
|
||||||
|
const envelopeId = page.url().split('/').pop()?.split('?')[0];
|
||||||
|
|
||||||
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||||
|
where: { id: envelopeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(envelope.status).toBe(DocumentStatus.PENDING);
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
import { reconcileSeatBasedPlans } from './update-subscription-item-quantity';
|
||||||
|
|
||||||
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
||||||
|
|
||||||
@@ -229,6 +230,19 @@ const handleLiveSubscription = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Detect a billing-period roll by comparing the persisted period end with
|
||||||
|
// the freshly-fetched one — the convergent equivalent of the old
|
||||||
|
// `previous_attributes.current_period_start` signal. On renewal, reconcile
|
||||||
|
// the seat quantity and claim down to the actual member count. The reconcile
|
||||||
|
// itself no-ops for non-seat/unlimited plans and inactive subscriptions.
|
||||||
|
const previousPeriodEnd = organisation.subscription?.periodEnd ?? null;
|
||||||
|
|
||||||
|
const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime();
|
||||||
|
|
||||||
|
if (hasPeriodAdvanced && !bypassClaimUpdate) {
|
||||||
|
await reconcileSeatBasedPlans(organisation.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { stripe } from '@documenso/lib/server-only/stripe';
|
|||||||
import { appLog } from '@documenso/lib/utils/debugger';
|
import { appLog } from '@documenso/lib/utils/debugger';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { OrganisationClaim, Subscription } from '@prisma/client';
|
import type { OrganisationClaim, Subscription } from '@prisma/client';
|
||||||
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
import { isPriceSeatsBased } from './is-price-seats-based';
|
import { isPriceSeatsBased } from './is-price-seats-based';
|
||||||
@@ -11,12 +12,14 @@ export type UpdateSubscriptionItemQuantityOptions = {
|
|||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
priceId: string;
|
priceId: string;
|
||||||
|
prorationBehaviour: 'always_invoice' | 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateSubscriptionItemQuantity = async ({
|
export const updateSubscriptionItemQuantity = async ({
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
quantity,
|
quantity,
|
||||||
priceId,
|
priceId,
|
||||||
|
prorationBehaviour,
|
||||||
}: UpdateSubscriptionItemQuantityOptions) => {
|
}: UpdateSubscriptionItemQuantityOptions) => {
|
||||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
@@ -26,7 +29,6 @@ export const updateSubscriptionItemQuantity = async ({
|
|||||||
throw new Error('Subscription does not contain required item');
|
throw new Error('Subscription does not contain required item');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year');
|
|
||||||
const oldQuantity = items[0].quantity;
|
const oldQuantity = items[0].quantity;
|
||||||
|
|
||||||
if (oldQuantity === quantity) {
|
if (oldQuantity === quantity) {
|
||||||
@@ -38,13 +40,12 @@ export const updateSubscriptionItemQuantity = async ({
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
quantity,
|
quantity,
|
||||||
})),
|
})),
|
||||||
|
proration_behavior: prorationBehaviour,
|
||||||
|
// Need to "off_session" updates since adding 3DS will have payments
|
||||||
|
// not pass through for these immediate invoices.
|
||||||
|
off_session: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only invoice immediately when changing the quantity of yearly item.
|
|
||||||
if (hasYearlyItem) {
|
|
||||||
subscriptionUpdatePayload.proration_behavior = 'always_invoice';
|
|
||||||
}
|
|
||||||
|
|
||||||
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,15 +56,19 @@ export const updateSubscriptionItemQuantity = async ({
|
|||||||
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
|
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
|
||||||
* organisation claim represents unlimited seats.
|
* organisation claim represents unlimited seats.
|
||||||
*
|
*
|
||||||
|
* Organisations without a subscription (e.g. after being downgraded to the
|
||||||
|
* free plan) can pass `null`, in which case the claim cap is enforced
|
||||||
|
* directly without the seats-based exemption.
|
||||||
|
*
|
||||||
* Should only be called from grow paths (invite/add). Reducing operations
|
* Should only be called from grow paths (invite/add). Reducing operations
|
||||||
* must never be gated by this check.
|
* must never be gated by this check.
|
||||||
*
|
*
|
||||||
* @param subscription - The organisation's Stripe subscription.
|
* @param subscription - The organisation's Stripe subscription, if any.
|
||||||
* @param organisationClaim - The organisation claim.
|
* @param organisationClaim - The organisation claim.
|
||||||
* @param quantity - The proposed total member + pending invite count.
|
* @param quantity - The proposed total member count.
|
||||||
*/
|
*/
|
||||||
export const assertMemberCountWithinCap = async (
|
export const assertMemberCountWithinCap = async (
|
||||||
subscription: Subscription,
|
subscription: Subscription | null,
|
||||||
organisationClaim: OrganisationClaim,
|
organisationClaim: OrganisationClaim,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
) => {
|
) => {
|
||||||
@@ -75,10 +80,12 @@ export const assertMemberCountWithinCap = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Seats-based plans don't have a hard cap; Stripe meters the usage.
|
// Seats-based plans don't have a hard cap; Stripe meters the usage.
|
||||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
if (subscription) {
|
||||||
|
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||||
|
|
||||||
if (isSeatsBased) {
|
if (isSeatsBased) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quantity > maximumMemberCount) {
|
if (quantity > maximumMemberCount) {
|
||||||
@@ -89,48 +96,134 @@ export const assertMemberCountWithinCap = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs the organisation's member count with the Stripe subscription quantity.
|
* Syncs the Stripe subscription quantity with the organisation's member count.
|
||||||
*
|
*
|
||||||
* No-ops for plans that are not seats-based, and for organisations with
|
* This is a Stripe <-> Database sync operation.
|
||||||
* unlimited seats (`organisationClaim.memberCount === 0`). Safe to call from
|
*
|
||||||
* both grow and shrink paths.
|
* Note: `organisationClaim.memberCount` is the paid seat high-water mark for the
|
||||||
|
* current billing period — the highest count we've already billed for.
|
||||||
*
|
*
|
||||||
* @param subscription - The subscription to sync the member count with.
|
* @param subscription - The subscription to sync the member count with.
|
||||||
* @param organisationClaim - The organisation claim.
|
* @param organisationClaim - The organisation claim.
|
||||||
* @param quantity - The new total member + pending invite count to sync.
|
* @param quantity - The new total member count to sync.
|
||||||
|
* @param mode - The member-count change that triggered the sync.
|
||||||
*/
|
*/
|
||||||
export const syncMemberCountWithStripeSeatPlan = async (
|
export const syncMemberCountWithStripeSeatPlan = async (
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
organisationClaim: OrganisationClaim,
|
organisationClaim: OrganisationClaim,
|
||||||
quantity: number,
|
quantity: number,
|
||||||
|
mode: 'grow' | 'shrink',
|
||||||
) => {
|
) => {
|
||||||
// Infinite seats means no sync needed.
|
// Unlimited seats — nothing to meter.
|
||||||
if (organisationClaim.memberCount === 0) {
|
if (organisationClaim.memberCount === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||||
|
|
||||||
|
// Only seat-based plans support seat syncing.
|
||||||
if (!isSeatsBased) {
|
if (!isSeatsBased) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
appLog('BILLING', 'Updating seat based plan');
|
// Whether to immediately invoice for new seats if the quantity is greater than
|
||||||
|
// the high-water mark.
|
||||||
|
const billsForNewSeats = mode === 'grow' && quantity > organisationClaim.memberCount;
|
||||||
|
|
||||||
|
appLog('BILLING', `Syncing seat based plan (${mode}, quantity ${quantity})`);
|
||||||
|
|
||||||
await updateSubscriptionItemQuantity({
|
await updateSubscriptionItemQuantity({
|
||||||
priceId: subscription.priceId,
|
priceId: subscription.priceId,
|
||||||
subscriptionId: subscription.planId,
|
subscriptionId: subscription.planId,
|
||||||
quantity,
|
quantity,
|
||||||
|
prorationBehaviour: billsForNewSeats ? 'always_invoice' : 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance the high-water mark when billing for new seats; it is reset to the
|
||||||
|
// actual member count when the billing period rolls over. Re-adds and shrinks
|
||||||
|
// deliberately leave it untouched so a seat already paid for this period is
|
||||||
|
// never re-charged.
|
||||||
|
if (billsForNewSeats) {
|
||||||
|
await prisma.organisationClaim.update({
|
||||||
|
where: {
|
||||||
|
id: organisationClaim.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
memberCount: quantity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconciles the organisation claim seat counter, and the stripe quantity with the
|
||||||
|
* actual member count.
|
||||||
|
*
|
||||||
|
* Uses the member count as the authoritative source of truth. Meaning:
|
||||||
|
* - Update the organisation claim with the member count
|
||||||
|
* - Update the Stripe subscription quantity to the member count
|
||||||
|
*
|
||||||
|
* This should only be called when the billing period rolls over.
|
||||||
|
*/
|
||||||
|
export const reconcileSeatBasedPlans = async (organisationId: string) => {
|
||||||
|
const organisation = await prisma.organisation.findFirst({
|
||||||
|
where: {
|
||||||
|
id: organisationId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
organisationClaim: true,
|
||||||
|
subscription: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisation || !organisation.subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subscription, organisationClaim } = organisation;
|
||||||
|
|
||||||
|
// Stripe rejects quantity updates on canceled subscriptions. PAST_DUE is
|
||||||
|
// still live and a no-proration sync is safe, so it's allowed through.
|
||||||
|
if (subscription.status === SubscriptionStatus.INACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlimited seats — nothing to meter.
|
||||||
|
if (organisationClaim.memberCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||||
|
|
||||||
|
// Only seat-based plans support seat syncing.
|
||||||
|
if (!isSeatsBased) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCount = await prisma.organisationMember.count({
|
||||||
|
where: {
|
||||||
|
organisationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// An organisation always retains its owner; never write the unlimited sentinel.
|
||||||
|
if (memberCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSubscriptionItemQuantity({
|
||||||
|
priceId: subscription.priceId,
|
||||||
|
subscriptionId: subscription.planId,
|
||||||
|
quantity: memberCount,
|
||||||
|
prorationBehaviour: 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
// This should be automatically updated after the Stripe webhook is fired
|
|
||||||
// but we just manually adjust it here as well to avoid any race conditions.
|
|
||||||
await prisma.organisationClaim.update({
|
await prisma.organisationClaim.update({
|
||||||
where: {
|
where: {
|
||||||
id: organisationClaim.id,
|
id: organisationClaim.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
memberCount: quantity,
|
memberCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|||||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
|
|
||||||
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
|
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
|
||||||
|
|
||||||
type StripeWebhookResponse = {
|
type StripeWebhookResponse = {
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export enum AppErrorCode {
|
|||||||
*/
|
*/
|
||||||
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
|
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signer recipient does not have a signature field assigned. Thrown when
|
||||||
|
* distributing an envelope or using a direct template where at least one
|
||||||
|
* signer has no signature field.
|
||||||
|
*/
|
||||||
|
MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
|
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
|
||||||
* for the recovery taxonomy.
|
* for the recovery taxonomy.
|
||||||
@@ -84,6 +91,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
|||||||
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
|
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
|
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
||||||
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
||||||
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||||
@@ -291,6 +299,7 @@ export class AppError extends Error {
|
|||||||
AppErrorCode.ENVELOPE_CANCELLED,
|
AppErrorCode.ENVELOPE_CANCELLED,
|
||||||
AppErrorCode.ENVELOPE_LEGACY,
|
AppErrorCode.ENVELOPE_LEGACY,
|
||||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||||
|
AppErrorCode.MISSING_SIGNATURE_FIELD,
|
||||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||||
AppErrorCode.CSC_CERT_INVALID,
|
AppErrorCode.CSC_CERT_INVALID,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emai
|
|||||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
||||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||||
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
|
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
|
||||||
|
import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/internal/alert-organisation-seat-drift';
|
||||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||||
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
||||||
@@ -29,6 +30,7 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume
|
|||||||
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
|
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
|
||||||
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
|
import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
|
||||||
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
|
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
|
||||||
|
import { SYNC_ORGANISATION_SEATS_JOB_DEFINITION } from './definitions/internal/sync-organisation-seats';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `as const` assertion is load bearing as it provides the correct level of type inference for
|
* The `as const` assertion is load bearing as it provides the correct level of type inference for
|
||||||
@@ -64,7 +66,9 @@ export const jobsClient = new JobClient([
|
|||||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||||
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
||||||
|
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
|
||||||
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
||||||
|
SYNC_ORGANISATION_SEATS_JOB_DEFINITION,
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
export const jobs = jobsClient;
|
export const jobs = jobsClient;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '../../../constants/app';
|
||||||
|
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
|
||||||
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
|
import type { TAlertOrganisationSeatDriftJobDefinition } from './alert-organisation-seat-drift';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily check for organisations whose member count exceeds their paid seat
|
||||||
|
* count (`organisationClaim.memberCount`, where `0` means unlimited).
|
||||||
|
*/
|
||||||
|
export const run = async ({ io }: { payload: TAlertOrganisationSeatDriftJobDefinition; io: JobRunIO }) => {
|
||||||
|
if (!IS_BILLING_ENABLED()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisations = await prisma.organisation.findMany({
|
||||||
|
where: {
|
||||||
|
// Exclude unlimited-seat plans (memberCount === 0).
|
||||||
|
organisationClaim: {
|
||||||
|
memberCount: {
|
||||||
|
not: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
organisationClaim: {
|
||||||
|
select: {
|
||||||
|
memberCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const driftedOrganisations = organisations.filter(
|
||||||
|
(organisation) =>
|
||||||
|
organisation.organisationClaim !== null &&
|
||||||
|
organisation._count.members > organisation.organisationClaim.memberCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (driftedOrganisations.length === 0) {
|
||||||
|
io.logger.info('No organisations exceed their paid seat count');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: SUPPORT_EMAIL,
|
||||||
|
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||||
|
subject: `[Billing] ${driftedOrganisations.length} organisation(s) exceed their paid seat count`,
|
||||||
|
text: [
|
||||||
|
`${driftedOrganisations.length} organisation(s) have more members than their paid seat count:`,
|
||||||
|
'',
|
||||||
|
...driftedOrganisations.map(
|
||||||
|
(organisation) =>
|
||||||
|
`- ${organisation.name} (${organisation.id}): ${organisation._count.members} members vs ${organisation.organisationClaim?.memberCount ?? 0} paid seats`,
|
||||||
|
),
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JobDefinition } from '../../client/_internal/job';
|
||||||
|
|
||||||
|
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID = 'internal.alert-organisation-seat-drift';
|
||||||
|
|
||||||
|
const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA = z.object({});
|
||||||
|
|
||||||
|
export type TAlertOrganisationSeatDriftJobDefinition = z.infer<
|
||||||
|
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION = {
|
||||||
|
id: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||||
|
name: 'Alert Organisation Seat Drift',
|
||||||
|
version: '1.0.0',
|
||||||
|
trigger: {
|
||||||
|
name: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||||
|
schema: ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_SCHEMA,
|
||||||
|
cron: '0 0 * * *', // Once a day at midnight.
|
||||||
|
},
|
||||||
|
handler: async ({ payload, io }) => {
|
||||||
|
const handler = await import('./alert-organisation-seat-drift.handler');
|
||||||
|
|
||||||
|
await handler.run({ payload, io });
|
||||||
|
},
|
||||||
|
} as const satisfies JobDefinition<
|
||||||
|
typeof ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION_ID,
|
||||||
|
TAlertOrganisationSeatDriftJobDefinition
|
||||||
|
>;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
|
import { IS_BILLING_ENABLED } from '../../../constants/app';
|
||||||
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
|
import type { TSyncOrganisationSeatsJobDefinition } from './sync-organisation-seats';
|
||||||
|
|
||||||
|
export const run = async ({ payload }: { payload: TSyncOrganisationSeatsJobDefinition; io: JobRunIO }) => {
|
||||||
|
const { organisationId } = payload;
|
||||||
|
|
||||||
|
if (!IS_BILLING_ENABLED()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisation = await prisma.organisation.findUnique({
|
||||||
|
where: {
|
||||||
|
id: organisationId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
subscription: true,
|
||||||
|
organisationClaim: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisation || !organisation.subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip canceled/terminal subscriptions — Stripe rejects quantity updates on a
|
||||||
|
// canceled subscription. PAST_DUE is still live and a no-proration shrink is
|
||||||
|
// safe, so it's allowed through.
|
||||||
|
if (organisation.subscription.status === SubscriptionStatus.INACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCount = await prisma.organisationMember.count({
|
||||||
|
where: {
|
||||||
|
organisationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// An organisation always retains its owner; guarding zero avoids writing the
|
||||||
|
// unlimited sentinel to the claim.
|
||||||
|
if (memberCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncMemberCountWithStripeSeatPlan(
|
||||||
|
organisation.subscription,
|
||||||
|
organisation.organisationClaim,
|
||||||
|
memberCount,
|
||||||
|
'shrink',
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JobDefinition } from '../../client/_internal/job';
|
||||||
|
|
||||||
|
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID = 'internal.sync-organisation-seats';
|
||||||
|
|
||||||
|
const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA = z.object({
|
||||||
|
organisationId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSyncOrganisationSeatsJobDefinition = z.infer<typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA>;
|
||||||
|
|
||||||
|
export const SYNC_ORGANISATION_SEATS_JOB_DEFINITION = {
|
||||||
|
id: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||||
|
name: 'Sync Organisation Seats',
|
||||||
|
version: '1.0.0',
|
||||||
|
trigger: {
|
||||||
|
name: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||||
|
schema: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA,
|
||||||
|
},
|
||||||
|
handler: async ({ payload, io }) => {
|
||||||
|
const handler = await import('./sync-organisation-seats.handler');
|
||||||
|
|
||||||
|
await handler.run({ payload, io });
|
||||||
|
},
|
||||||
|
} as const satisfies JobDefinition<
|
||||||
|
typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID,
|
||||||
|
TSyncOrganisationSeatsJobDefinition
|
||||||
|
>;
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
|
||||||
|
export const ADMIN_SEARCH_RESULTS_PER_TYPE = 5;
|
||||||
|
|
||||||
|
const MAX_POSTGRES_INT = 2147483647;
|
||||||
|
|
||||||
|
const GROUP_ORDER = ['document', 'user', 'organisation', 'team', 'recipient', 'subscription'] as const;
|
||||||
|
|
||||||
|
export type AdminGlobalSearchResultType = (typeof GROUP_ORDER)[number];
|
||||||
|
|
||||||
|
export type AdminGlobalSearchResult = {
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
path: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminGlobalSearchGroup = {
|
||||||
|
type: AdminGlobalSearchResultType;
|
||||||
|
results: AdminGlobalSearchResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminGlobalSearchOptions = {
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartialResults = Partial<Record<AdminGlobalSearchResultType, AdminGlobalSearchResult[]>>;
|
||||||
|
|
||||||
|
export const adminGlobalSearch = async ({ query }: AdminGlobalSearchOptions): Promise<AdminGlobalSearchGroup[]> => {
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
|
||||||
|
if (trimmedQuery.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultsByType = await resolveSearch(trimmedQuery);
|
||||||
|
|
||||||
|
return GROUP_ORDER.map((type) => ({
|
||||||
|
type,
|
||||||
|
results: (resultsByType[type] ?? []).map((result) => ({
|
||||||
|
...result,
|
||||||
|
// Append the raw query so cmdk's client-side filter never hides
|
||||||
|
// server-verified results.
|
||||||
|
value: `${result.value} ${trimmedQuery}`,
|
||||||
|
})),
|
||||||
|
})).filter((group) => group.results.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveSearch = async (query: string): Promise<PartialResults> => {
|
||||||
|
// Recognized ID prefixes resolve to a single exact lookup.
|
||||||
|
if (query.startsWith('envelope_')) {
|
||||||
|
return { document: await findDocumentsByExactId({ id: query }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.startsWith('document_')) {
|
||||||
|
return { document: await findDocumentsByExactId({ secondaryId: query }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.startsWith('org_')) {
|
||||||
|
return { organisation: await findOrganisationsByIdOrUrl(query) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare numbers are treated as verified ID lookups only. Oversized numbers
|
||||||
|
// fall through to text search.
|
||||||
|
const numericId = Number(query);
|
||||||
|
|
||||||
|
if (/^\d+$/.test(query) && numericId <= MAX_POSTGRES_INT) {
|
||||||
|
const [document, user, team, recipient, subscription] = await Promise.all([
|
||||||
|
findDocumentsByExactId({ secondaryId: `document_${numericId}` }),
|
||||||
|
findUsersById(numericId),
|
||||||
|
findTeamsById(numericId),
|
||||||
|
findRecipientsById(numericId),
|
||||||
|
findSubscriptionsById(numericId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { document, user, team, recipient, subscription };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free text searches all resource types in parallel.
|
||||||
|
const [document, user, organisation, team, recipient, subscription] = await Promise.all([
|
||||||
|
findDocumentsByText(query),
|
||||||
|
findUsersByText(query),
|
||||||
|
findOrganisationsByText(query),
|
||||||
|
findTeamsByText(query),
|
||||||
|
findRecipientsByText(query),
|
||||||
|
findSubscriptionsByText(query),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
organisation,
|
||||||
|
team,
|
||||||
|
recipient,
|
||||||
|
subscription,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinSublabel = (parts: Array<string | null | undefined>) =>
|
||||||
|
parts.filter((part) => part && part.length > 0).join(' · ') || undefined;
|
||||||
|
|
||||||
|
// ─── Documents ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const documentSelect = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
secondaryId: true,
|
||||||
|
user: { select: { email: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type DocumentRow = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
secondaryId: string;
|
||||||
|
user: { email: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDocument = (envelope: DocumentRow): AdminGlobalSearchResult => ({
|
||||||
|
label: envelope.title,
|
||||||
|
sublabel: joinSublabel([envelope.secondaryId, envelope.user.email]),
|
||||||
|
path: `/admin/documents/${envelope.id}`,
|
||||||
|
value: `document ${envelope.id} ${envelope.secondaryId} ${envelope.title} ${envelope.user.email}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findDocumentsByExactId = async (where: { id: string } | { secondaryId: string }) => {
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: { ...where, type: EnvelopeType.DOCUMENT },
|
||||||
|
select: documentSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return envelope ? [mapDocument(envelope)] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDocumentsByText = async (query: string) => {
|
||||||
|
const envelopes = await prisma.envelope.findMany({
|
||||||
|
where: {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
title: { contains: query, mode: 'insensitive' },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: documentSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return envelopes.map(mapDocument);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const userSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type UserRow = { id: number; name: string | null; email: string };
|
||||||
|
|
||||||
|
const mapUser = (user: UserRow): AdminGlobalSearchResult => ({
|
||||||
|
label: user.name || user.email,
|
||||||
|
sublabel: joinSublabel([`#${user.id}`, user.email]),
|
||||||
|
path: `/admin/users/${user.id}`,
|
||||||
|
value: `user ${user.id} ${user.name ?? ''} ${user.email}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findUsersById = async (id: number) => {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { id },
|
||||||
|
select: userSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user ? [mapUser(user)] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findUsersByText = async (query: string) => {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ name: { contains: query, mode: 'insensitive' } }, { email: { contains: query, mode: 'insensitive' } }],
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: userSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return users.map(mapUser);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Organisations ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const organisationSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
owner: { select: { email: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type OrganisationRow = { id: string; name: string; owner: { email: string } };
|
||||||
|
|
||||||
|
const mapOrganisation = (organisation: OrganisationRow): AdminGlobalSearchResult => ({
|
||||||
|
label: organisation.name,
|
||||||
|
sublabel: joinSublabel([organisation.id, organisation.owner.email]),
|
||||||
|
path: `/admin/organisations/${organisation.id}`,
|
||||||
|
value: `organisation ${organisation.id} ${organisation.name} ${organisation.owner.email}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findOrganisationsByIdOrUrl = async (query: string) => {
|
||||||
|
const organisations = await prisma.organisation.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ id: query }, { url: query }],
|
||||||
|
},
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: organisationSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return organisations.map(mapOrganisation);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findOrganisationsByText = async (query: string) => {
|
||||||
|
const organisations = await prisma.organisation.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ url: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ customerId: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ owner: { email: { contains: query, mode: 'insensitive' } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: organisationSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return organisations.map(mapOrganisation);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Teams ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const teamSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
organisation: { select: { name: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type TeamRow = { id: number; name: string; url: string; organisation: { name: string } };
|
||||||
|
|
||||||
|
const mapTeam = (team: TeamRow): AdminGlobalSearchResult => ({
|
||||||
|
label: team.name,
|
||||||
|
sublabel: joinSublabel([`#${team.id}`, `/${team.url}`, team.organisation.name]),
|
||||||
|
path: `/admin/teams/${team.id}`,
|
||||||
|
value: `team ${team.id} ${team.name} ${team.url} ${team.organisation.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findTeamsById = async (id: number) => {
|
||||||
|
const team = await prisma.team.findFirst({
|
||||||
|
where: { id },
|
||||||
|
select: teamSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return team ? [mapTeam(team)] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findTeamsByText = async (query: string) => {
|
||||||
|
const teams = await prisma.team.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ name: { contains: query, mode: 'insensitive' } }, { url: { contains: query, mode: 'insensitive' } }],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: teamSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return teams.map(mapTeam);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Recipients ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const recipientSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
envelope: { select: { id: true, title: true } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type RecipientRow = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
envelope: { id: string; title: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapRecipient = (recipient: RecipientRow): AdminGlobalSearchResult => ({
|
||||||
|
label: recipient.email,
|
||||||
|
sublabel: joinSublabel([`#${recipient.id}`, recipient.name, recipient.envelope.title]),
|
||||||
|
path: `/admin/documents/${recipient.envelope.id}`,
|
||||||
|
value: `recipient ${recipient.id} ${recipient.name} ${recipient.email} ${recipient.envelope.title}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findRecipientsById = async (id: number) => {
|
||||||
|
const recipient = await prisma.recipient.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
envelope: { type: EnvelopeType.DOCUMENT },
|
||||||
|
},
|
||||||
|
select: recipientSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return recipient ? [mapRecipient(recipient)] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findRecipientsByText = async (query: string) => {
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
envelope: { type: EnvelopeType.DOCUMENT },
|
||||||
|
OR: [{ email: { contains: query, mode: 'insensitive' } }, { name: { contains: query, mode: 'insensitive' } }],
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: recipientSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return recipients.map(mapRecipient);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Subscriptions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const subscriptionSelect = {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
planId: true,
|
||||||
|
customerId: true,
|
||||||
|
organisationId: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SubscriptionRow = {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
planId: string;
|
||||||
|
customerId: string;
|
||||||
|
organisationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapSubscription = (subscription: SubscriptionRow): AdminGlobalSearchResult => ({
|
||||||
|
label: `Subscription #${subscription.id}`,
|
||||||
|
sublabel: joinSublabel([subscription.status, subscription.planId]),
|
||||||
|
path: `/admin/organisations/${subscription.organisationId}`,
|
||||||
|
value: `subscription ${subscription.id} ${subscription.planId} ${subscription.customerId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findSubscriptionsById = async (id: number) => {
|
||||||
|
const subscription = await prisma.subscription.findFirst({
|
||||||
|
where: { id },
|
||||||
|
select: subscriptionSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return subscription ? [mapSubscription(subscription)] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findSubscriptionsByText = async (query: string) => {
|
||||||
|
const subscriptions = await prisma.subscription.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ planId: { contains: query, mode: 'insensitive' } },
|
||||||
|
{ customerId: { contains: query, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: ADMIN_SEARCH_RESULTS_PER_TYPE,
|
||||||
|
select: subscriptionSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
return subscriptions.map(mapSubscription);
|
||||||
|
};
|
||||||
@@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
|||||||
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
|
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||||
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
|
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
|
|||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
|
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
|
||||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||||
|
import { getRecipientsWithMissingFields } from '../../utils/recipients';
|
||||||
import { extractFieldAutoInsertValues } from '../document/send-document';
|
import { extractFieldAutoInsertValues } from '../document/send-document';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
|
||||||
@@ -125,6 +126,17 @@ export const getEnvelopeForDirectTemplateSigning = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipientsWithMissingFields = getRecipientsWithMissingFields(
|
||||||
|
envelope.recipients,
|
||||||
|
envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recipientsWithMissingFields.length > 0) {
|
||||||
|
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||||
|
message: 'One or more signers on this direct template are missing a signature field',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const settings = await getTeamSettings({ teamId: envelope.teamId });
|
const settings = await getTeamSettings({ teamId: envelope.teamId });
|
||||||
|
|
||||||
const sender = settings.includeSenderDetails
|
const sender = settings.includeSenderDetails
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import {
|
||||||
|
assertMemberCountWithinCap,
|
||||||
|
syncMemberCountWithStripeSeatPlan,
|
||||||
|
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
|
import type { OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
|
||||||
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
|
import { OrganisationGroupType, OrganisationMemberInviteStatus, SubscriptionStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { jobs } from '../../jobs/client';
|
import { jobs } from '../../jobs/client';
|
||||||
import { generateDatabaseId } from '../../universal/id';
|
import { generateDatabaseId } from '../../universal/id';
|
||||||
@@ -22,6 +27,13 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
|
|||||||
organisation: {
|
organisation: {
|
||||||
include: {
|
include: {
|
||||||
groups: true,
|
groups: true,
|
||||||
|
organisationClaim: true,
|
||||||
|
subscription: true,
|
||||||
|
members: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -66,6 +78,35 @@ export const acceptOrganisationInvitation = async ({ token }: AcceptOrganisation
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newMemberCount = organisation.members.length + 1;
|
||||||
|
|
||||||
|
// Billing occurs when a user accepts an invite.
|
||||||
|
// Assert that the new member count is within the cap and sync the seat plan with Stripe.
|
||||||
|
if (IS_BILLING_ENABLED()) {
|
||||||
|
const { subscription, organisationClaim } = organisation;
|
||||||
|
|
||||||
|
// A canceled subscription cannot have its seat quantity updated in Stripe,
|
||||||
|
// and an organisation with lapsed billing should not gain new members.
|
||||||
|
// Throw a deliberate error so the invite page can render an accurate
|
||||||
|
// message instead of an opaque Stripe failure.
|
||||||
|
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
|
||||||
|
throw new AppError('SUBSCRIPTION_INACTIVE', {
|
||||||
|
message: 'The organisation subscription is inactive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organisations can exist without a subscription (e.g. after being
|
||||||
|
// downgraded to the free plan). The claim cap remains authoritative in
|
||||||
|
// that case, surfacing LIMIT_EXCEEDED instead of an opaque "subscription
|
||||||
|
// not found" error.
|
||||||
|
await assertMemberCountWithinCap(subscription, organisationClaim, newMemberCount);
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount, 'grow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todo: Logging
|
||||||
await addUserToOrganisation({
|
await addUserToOrganisation({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import {
|
|
||||||
assertMemberCountWithinCap,
|
|
||||||
syncMemberCountWithStripeSeatPlan,
|
|
||||||
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
|
||||||
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||||
@@ -17,7 +13,6 @@ import { createElement } from 'react';
|
|||||||
|
|
||||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||||
import { generateDatabaseId } from '../../universal/id';
|
import { generateDatabaseId } from '../../universal/id';
|
||||||
import { validateIfSubscriptionIsRequired } from '../../utils/billing';
|
|
||||||
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
@@ -62,8 +57,6 @@ export const createOrganisationMemberInvites = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
organisationGlobalSettings: true,
|
organisationGlobalSettings: true,
|
||||||
organisationClaim: true,
|
|
||||||
subscription: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,10 +64,6 @@ export const createOrganisationMemberInvites = async ({
|
|||||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { organisationClaim } = organisation;
|
|
||||||
|
|
||||||
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
|
|
||||||
|
|
||||||
const currentOrganisationMemberRole = await getMemberOrganisationRole({
|
const currentOrganisationMemberRole = await getMemberOrganisationRole({
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
reference: {
|
reference: {
|
||||||
@@ -120,19 +109,6 @@ export const createOrganisationMemberInvites = async ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const numberOfCurrentMembers = organisation.members.length;
|
|
||||||
const numberOfCurrentInvites = organisation.invites.length;
|
|
||||||
const numberOfNewInvites = organisationMemberInvites.length;
|
|
||||||
|
|
||||||
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
|
|
||||||
|
|
||||||
// Enforce the seat cap and sync billing for seat based plans.
|
|
||||||
if (subscription) {
|
|
||||||
await assertMemberCountWithinCap(subscription, organisationClaim, totalMemberCountWithInvites);
|
|
||||||
|
|
||||||
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, totalMemberCountWithInvites);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.organisationMemberInvite.createMany({
|
await prisma.organisationMemberInvite.createMany({
|
||||||
data: organisationMemberInvites,
|
data: organisationMemberInvites,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,13 +84,13 @@ export const syncSubscriptionRateLimit = createRateLimit({
|
|||||||
|
|
||||||
export const apiV1RateLimit = createRateLimit({
|
export const apiV1RateLimit = createRateLimit({
|
||||||
action: 'api.v1',
|
action: 'api.v1',
|
||||||
max: 100,
|
max: 1000,
|
||||||
window: '1m',
|
window: '1m',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiV2RateLimit = createRateLimit({
|
export const apiV2RateLimit = createRateLimit({
|
||||||
action: 'api.v2',
|
action: 'api.v2',
|
||||||
max: 100,
|
max: 1000,
|
||||||
window: '1m',
|
window: '1m',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
extractDocumentAuthMethods,
|
extractDocumentAuthMethods,
|
||||||
} from '../../utils/document-auth';
|
} from '../../utils/document-auth';
|
||||||
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||||
|
import { getRecipientsWithMissingFields } from '../../utils/recipients';
|
||||||
import { sendDocument } from '../document/send-document';
|
import { sendDocument } from '../document/send-document';
|
||||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||||
import { incrementDocumentId } from '../envelope/increment-id';
|
import { incrementDocumentId } from '../envelope/increment-id';
|
||||||
@@ -172,6 +173,17 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipientsWithMissingFields = getRecipientsWithMissingFields(
|
||||||
|
recipients,
|
||||||
|
recipients.flatMap((recipient) => recipient.fields),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recipientsWithMissingFields.length > 0) {
|
||||||
|
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
|
||||||
|
message: 'One or more signers on this direct template are missing a signature field',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
|
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
|
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import { jobs } from '../../jobs/client';
|
||||||
import { deleteOrganisation } from '../organisation/delete-organisation';
|
import { deleteOrganisation } from '../organisation/delete-organisation';
|
||||||
|
|
||||||
export type DeleteUserOptions = {
|
export type DeleteUserOptions = {
|
||||||
@@ -59,6 +60,13 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Organisations the user is a member of (but not owner). Owned organisations
|
||||||
|
// are fully torn down below (including subscription cancellation), so only
|
||||||
|
// these need a seat sync after the user's memberships cascade away.
|
||||||
|
const memberOrganisationIds = user.organisationMember
|
||||||
|
.filter((member) => member.organisation.ownerUserId !== user.id)
|
||||||
|
.map((member) => member.organisationId);
|
||||||
|
|
||||||
// For organisations the user owns - fully tear them down (orphan envelopes,
|
// For organisations the user owns - fully tear them down (orphan envelopes,
|
||||||
// delete the organisation, and cancel any Stripe subscription). Without this
|
// delete the organisation, and cancel any Stripe subscription). Without this
|
||||||
// the organisations would only cascade away when the user row is deleted,
|
// the organisations would only cascade away when the user row is deleted,
|
||||||
@@ -82,9 +90,21 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return await prisma.user.delete({
|
const deletedUser = await prisma.user.delete({
|
||||||
where: {
|
where: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The user's memberships were cascade-deleted with the user row — queue a
|
||||||
|
// seat sync for each organisation they belonged to so the Stripe quantity
|
||||||
|
// trues down to the new member count (no proration, no credit).
|
||||||
|
for (const organisationId of memberOrganisationIds) {
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'internal.sync-organisation-seats',
|
||||||
|
payload: { organisationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedUser;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -361,9 +361,9 @@ export const seedAlignmentTestDocument = async ({
|
|||||||
const { id, recipients, envelopeItems } = createdEnvelope;
|
const { id, recipients, envelopeItems } = createdEnvelope;
|
||||||
|
|
||||||
if (isDirectTemplate) {
|
if (isDirectTemplate) {
|
||||||
const directTemplateRecpient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
|
const directTemplateRecipient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
|
||||||
|
|
||||||
if (!directTemplateRecpient) {
|
if (!directTemplateRecipient) {
|
||||||
throw new Error('Need to create a direct template recipient');
|
throw new Error('Need to create a direct template recipient');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +372,7 @@ export const seedAlignmentTestDocument = async ({
|
|||||||
envelopeId: id,
|
envelopeId: id,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
token: directTemplateToken ?? Math.random().toString(),
|
token: directTemplateToken ?? Math.random().toString(),
|
||||||
directTemplateRecipientId: directTemplateRecpient.id,
|
directTemplateRecipientId: directTemplateRecipient.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
DIRECT_TEMPLATE_RECIPIENT_NAME,
|
DIRECT_TEMPLATE_RECIPIENT_NAME,
|
||||||
} from '@documenso/lib/constants/direct-templates';
|
} from '@documenso/lib/constants/direct-templates';
|
||||||
import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
|
import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
|
||||||
|
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||||
import { SignatureLevel } from '@documenso/lib/types/signature-level';
|
import { SignatureLevel } from '@documenso/lib/types/signature-level';
|
||||||
import { prefixedId } from '@documenso/lib/universal/id';
|
import { prefixedId } from '@documenso/lib/universal/id';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
DocumentDataType,
|
DocumentDataType,
|
||||||
DocumentSource,
|
DocumentSource,
|
||||||
EnvelopeType,
|
EnvelopeType,
|
||||||
|
FieldType,
|
||||||
ReadStatus,
|
ReadStatus,
|
||||||
RecipientRole,
|
RecipientRole,
|
||||||
SendStatus,
|
SendStatus,
|
||||||
@@ -29,6 +31,11 @@ type SeedTemplateOptions = {
|
|||||||
teamId: number;
|
teamId: number;
|
||||||
internalVersion?: 1 | 2;
|
internalVersion?: 1 | 2;
|
||||||
createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
|
createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
|
||||||
|
/**
|
||||||
|
* Only used by seedDirectTemplate. Creates a signature field for the direct
|
||||||
|
* recipient so the seeded direct template is valid. Defaults to true.
|
||||||
|
*/
|
||||||
|
createDirectRecipientSignatureField?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateTemplateOptions = {
|
type CreateTemplateOptions = {
|
||||||
@@ -198,11 +205,11 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const directTemplateRecpient = template.recipients.find(
|
const directTemplateRecipient = template.recipients.find(
|
||||||
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!directTemplateRecpient) {
|
if (!directTemplateRecipient) {
|
||||||
throw new Error('Need to create a direct template recipient');
|
throw new Error('Need to create a direct template recipient');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,10 +218,35 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
|
|||||||
envelopeId: template.id,
|
envelopeId: template.id,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
token: Math.random().toString(),
|
token: Math.random().toString(),
|
||||||
directTemplateRecipientId: directTemplateRecpient.id,
|
directTemplateRecipientId: directTemplateRecipient.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { createDirectRecipientSignatureField = true } = options;
|
||||||
|
|
||||||
|
if (createDirectRecipientSignatureField) {
|
||||||
|
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
|
||||||
|
where: { envelopeId: template.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.field.create({
|
||||||
|
data: {
|
||||||
|
envelopeId: template.id,
|
||||||
|
envelopeItemId: envelopeItem.id,
|
||||||
|
recipientId: directTemplateRecipient.id,
|
||||||
|
type: FieldType.SIGNATURE,
|
||||||
|
page: 1,
|
||||||
|
positionX: 5,
|
||||||
|
positionY: 10,
|
||||||
|
width: 20,
|
||||||
|
height: 5,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return await prisma.envelope.findFirstOrThrow({
|
return await prisma.envelope.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: template.id,
|
id: template.id,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { adminGlobalSearch } from '@documenso/lib/server-only/admin/admin-global-search';
|
||||||
|
|
||||||
|
import { adminProcedure } from '../trpc';
|
||||||
|
import { ZAdminSearchRequestSchema, ZAdminSearchResponseSchema } from './admin-search.types';
|
||||||
|
|
||||||
|
export const adminSearchRoute = adminProcedure
|
||||||
|
.input(ZAdminSearchRequestSchema)
|
||||||
|
.output(ZAdminSearchResponseSchema)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { query } = input;
|
||||||
|
|
||||||
|
const groups = await adminGlobalSearch({ query });
|
||||||
|
|
||||||
|
return { groups };
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZAdminSearchResultTypeSchema = z.enum([
|
||||||
|
'document',
|
||||||
|
'user',
|
||||||
|
'organisation',
|
||||||
|
'team',
|
||||||
|
'recipient',
|
||||||
|
'subscription',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ZAdminSearchResultSchema = z.object({
|
||||||
|
label: z.string(),
|
||||||
|
sublabel: z.string().optional(),
|
||||||
|
path: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ADMIN_SEARCH_MAX_QUERY_LENGTH = 100;
|
||||||
|
|
||||||
|
export const ZAdminSearchRequestSchema = z.object({
|
||||||
|
query: z.string().trim().min(1).max(ADMIN_SEARCH_MAX_QUERY_LENGTH),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZAdminSearchResponseSchema = z.object({
|
||||||
|
groups: z.array(
|
||||||
|
z.object({
|
||||||
|
type: ZAdminSearchResultTypeSchema,
|
||||||
|
results: ZAdminSearchResultSchema.array(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAdminSearchResultType = z.infer<typeof ZAdminSearchResultTypeSchema>;
|
||||||
|
export type TAdminSearchResult = z.infer<typeof ZAdminSearchResultSchema>;
|
||||||
|
export type TAdminSearchRequest = z.infer<typeof ZAdminSearchRequestSchema>;
|
||||||
|
export type TAdminSearchResponse = z.infer<typeof ZAdminSearchResponseSchema>;
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { jobs } from '@documenso/lib/jobs/client';
|
import { jobs } from '@documenso/lib/jobs/client';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
|
||||||
|
|
||||||
import { adminProcedure } from '../trpc';
|
import { adminProcedure } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@@ -28,8 +26,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
|
|||||||
id: organisationId,
|
id: organisationId,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
subscription: true,
|
|
||||||
organisationClaim: true,
|
|
||||||
teams: {
|
teams: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -41,14 +37,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
|
|||||||
userId: true,
|
userId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invites: {
|
|
||||||
where: {
|
|
||||||
status: OrganisationMemberInviteStatus.PENDING,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,18 +60,6 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const newMemberCount = organisation.members.length + organisation.invites.length - 1;
|
|
||||||
|
|
||||||
// Removing a member is a reducing operation, so we don't gate it on the
|
|
||||||
// subscription being present. Sync Stripe only when one exists.
|
|
||||||
if (organisation.subscription) {
|
|
||||||
await syncMemberCountWithStripeSeatPlan(
|
|
||||||
organisation.subscription,
|
|
||||||
organisation.organisationClaim,
|
|
||||||
newMemberCount,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamIds = organisation.teams.map((team) => team.id);
|
const teamIds = organisation.teams.map((team) => team.id);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
@@ -113,6 +89,13 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A member was removed — queue a seat sync to true the Stripe quantity down
|
||||||
|
// to the new count (no proration, no credit).
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'internal.sync-organisation-seats',
|
||||||
|
payload: { organisationId },
|
||||||
|
});
|
||||||
|
|
||||||
await jobs.triggerJob({
|
await jobs.triggerJob({
|
||||||
name: 'send.organisation-member-left.email',
|
name: 'send.organisation-member-left.email',
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { router } from '../trpc';
|
import { router } from '../trpc';
|
||||||
|
import { adminSearchRoute } from './admin-search';
|
||||||
import { createAdminOrganisationRoute } from './create-admin-organisation';
|
import { createAdminOrganisationRoute } from './create-admin-organisation';
|
||||||
import { createStripeCustomerRoute } from './create-stripe-customer';
|
import { createStripeCustomerRoute } from './create-stripe-customer';
|
||||||
import { createSubscriptionClaimRoute } from './create-subscription-claim';
|
import { createSubscriptionClaimRoute } from './create-subscription-claim';
|
||||||
@@ -118,5 +119,6 @@ export const adminRouter = router({
|
|||||||
teamMember: {
|
teamMember: {
|
||||||
delete: deleteAdminTeamMemberRoute,
|
delete: deleteAdminTeamMemberRoute,
|
||||||
},
|
},
|
||||||
|
search: adminSearchRoute,
|
||||||
updateSiteSetting: updateSiteSettingRoute,
|
updateSiteSetting: updateSiteSettingRoute,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ export const createAttachmentRoute = authenticatedProcedure
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/attachment/create',
|
path: '/document/attachment/create',
|
||||||
summary: 'Create attachment',
|
summary: 'Create attachment',
|
||||||
description: 'Create a new attachment for a document',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a new attachment for a document',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateAttachmentRequestSchema)
|
.input(ZCreateAttachmentRequestSchema)
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ export const deleteAttachmentRoute = authenticatedProcedure
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/attachment/delete',
|
path: '/document/attachment/delete',
|
||||||
summary: 'Delete attachment',
|
summary: 'Delete attachment',
|
||||||
description: 'Delete an attachment from a document',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Delete an attachment from a document',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZDeleteAttachmentRequestSchema)
|
.input(ZDeleteAttachmentRequestSchema)
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ export const findAttachmentsRoute = authenticatedProcedure
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/document/attachment',
|
path: '/document/attachment',
|
||||||
summary: 'Find attachments',
|
summary: 'Find attachments',
|
||||||
description: 'Find all attachments for a document',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Find all attachments for a document',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZFindAttachmentsRequestSchema)
|
.input(ZFindAttachmentsRequestSchema)
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ export const updateAttachmentRoute = authenticatedProcedure
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/attachment/update',
|
path: '/document/attachment/update',
|
||||||
summary: 'Update attachment',
|
summary: 'Update attachment',
|
||||||
description: 'Update an existing attachment',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update an existing attachment',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZUpdateAttachmentRequestSchema)
|
.input(ZUpdateAttachmentRequestSchema)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const createDocumentTemporaryMeta: TrpcRouteMeta = {
|
|||||||
path: '/document/create/beta',
|
path: '/document/create/beta',
|
||||||
summary: 'Create document',
|
summary: 'Create document',
|
||||||
description:
|
description:
|
||||||
'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.',
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. You will need to upload the PDF to the provided URL returned. This endpoint will be removed since we will allow direct uploads, instead of using an upload URL.',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ export const createDocumentMeta: TrpcRouteMeta = {
|
|||||||
path: '/document/create',
|
path: '/document/create',
|
||||||
contentTypes: ['multipart/form-data'],
|
contentTypes: ['multipart/form-data'],
|
||||||
summary: 'Create document',
|
summary: 'Create document',
|
||||||
description: 'Create a document using form data.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ export const deleteDocumentMeta: TrpcRouteMeta = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/delete',
|
path: '/document/delete',
|
||||||
summary: 'Delete document',
|
summary: 'Delete document',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ export const distributeDocumentMeta: TrpcRouteMeta = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/distribute',
|
path: '/document/distribute',
|
||||||
summary: 'Distribute document',
|
summary: 'Distribute document',
|
||||||
description: 'Send the document out to recipients based on your distribution method',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Send the document out to recipients based on your distribution method',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ export const downloadDocumentMeta: TrpcRouteMeta = {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/document/{documentId}/download-beta',
|
path: '/document/{documentId}/download-beta',
|
||||||
summary: 'Download document (beta)',
|
summary: 'Download document (beta)',
|
||||||
description: 'Get a pre-signed download URL for the original or signed version of a document',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Get a pre-signed download URL for the original or signed version of a document',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ export const downloadDocumentMeta: TrpcRouteMeta = {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/document/{documentId}/download',
|
path: '/document/{documentId}/download',
|
||||||
summary: 'Download document',
|
summary: 'Download document',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
responseHeaders: z.object({
|
responseHeaders: z.object({
|
||||||
'Content-Type': z.literal('application/pdf'),
|
'Content-Type': z.literal('application/pdf'),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ export const duplicateDocumentMeta: TrpcRouteMeta = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/duplicate',
|
path: '/document/duplicate',
|
||||||
summary: 'Duplicate document',
|
summary: 'Duplicate document',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ export const ZFindDocumentsMeta: TrpcRouteMeta = {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/document',
|
path: '/document',
|
||||||
summary: 'Find documents',
|
summary: 'Find documents',
|
||||||
description: 'Find documents based on a search criteria',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Find documents based on a search criteria',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ export const getDocumentMeta: TrpcRouteMeta = {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/document/{documentId}',
|
path: '/document/{documentId}',
|
||||||
summary: 'Get document',
|
summary: 'Get document',
|
||||||
description: 'Returns a document given an ID',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Returns a document given an ID',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ export const getDocumentsByIdsMeta: TrpcRouteMeta = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/get-many',
|
path: '/document/get-many',
|
||||||
summary: 'Get multiple documents',
|
summary: 'Get multiple documents',
|
||||||
description: 'Retrieve multiple documents by their IDs',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Retrieve multiple documents by their IDs',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ export const redistributeDocumentMeta: TrpcRouteMeta = {
|
|||||||
path: '/document/redistribute',
|
path: '/document/redistribute',
|
||||||
summary: 'Redistribute document',
|
summary: 'Redistribute document',
|
||||||
description:
|
description:
|
||||||
'Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document',
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export const updateDocumentMeta: TrpcRouteMeta = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/update',
|
path: '/document/update',
|
||||||
summary: 'Update document',
|
summary: 'Update document',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Document'],
|
tags: ['Document'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ export const fieldRouter = router({
|
|||||||
path: '/document/field/{fieldId}',
|
path: '/document/field/{fieldId}',
|
||||||
summary: 'Get document field',
|
summary: 'Get document field',
|
||||||
description:
|
description:
|
||||||
'Returns a single field. If you want to retrieve all the fields for a document, use the "Get Document" endpoint.',
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Returns a single field. If you want to retrieve all the fields for a document, use the "Get Document" endpoint.',
|
||||||
tags: ['Document Fields'],
|
tags: ['Document Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZGetFieldRequestSchema)
|
.input(ZGetFieldRequestSchema)
|
||||||
@@ -84,8 +85,10 @@ export const fieldRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/field/create',
|
path: '/document/field/create',
|
||||||
summary: 'Create document field',
|
summary: 'Create document field',
|
||||||
description: 'Create a single field for a document.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a single field for a document.',
|
||||||
tags: ['Document Fields'],
|
tags: ['Document Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateDocumentFieldRequestSchema)
|
.input(ZCreateDocumentFieldRequestSchema)
|
||||||
@@ -130,8 +133,10 @@ export const fieldRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/field/create-many',
|
path: '/document/field/create-many',
|
||||||
summary: 'Create document fields',
|
summary: 'Create document fields',
|
||||||
description: 'Create multiple fields for a document.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create multiple fields for a document.',
|
||||||
tags: ['Document Fields'],
|
tags: ['Document Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateDocumentFieldsRequestSchema)
|
.input(ZCreateDocumentFieldsRequestSchema)
|
||||||
@@ -172,8 +177,10 @@ export const fieldRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/field/update',
|
path: '/document/field/update',
|
||||||
summary: 'Update document field',
|
summary: 'Update document field',
|
||||||
description: 'Update a single field for a document.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update a single field for a document.',
|
||||||
tags: ['Document Fields'],
|
tags: ['Document Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZUpdateDocumentFieldRequestSchema)
|
.input(ZUpdateDocumentFieldRequestSchema)
|
||||||
@@ -212,8 +219,10 @@ export const fieldRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/field/update-many',
|
path: '/document/field/update-many',
|
||||||
summary: 'Update document fields',
|
summary: 'Update document fields',
|
||||||
description: 'Update multiple fields for a document.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update multiple fields for a document.',
|
||||||
tags: ['Document Fields'],
|
tags: ['Document Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZUpdateDocumentFieldsRequestSchema)
|
.input(ZUpdateDocumentFieldsRequestSchema)
|
||||||
@@ -250,7 +259,10 @@ export const fieldRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/field/delete',
|
path: '/document/field/delete',
|
||||||
summary: 'Delete document field',
|
summary: 'Delete document field',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Document Fields'],
|
tags: ['Document Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZDeleteDocumentFieldRequestSchema)
|
.input(ZDeleteDocumentFieldRequestSchema)
|
||||||
@@ -323,8 +335,10 @@ export const fieldRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/field/create',
|
path: '/template/field/create',
|
||||||
summary: 'Create template field',
|
summary: 'Create template field',
|
||||||
description: 'Create a single field for a template.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a single field for a template.',
|
||||||
tags: ['Template Fields'],
|
tags: ['Template Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateTemplateFieldRequestSchema)
|
.input(ZCreateTemplateFieldRequestSchema)
|
||||||
@@ -370,8 +384,9 @@ export const fieldRouter = router({
|
|||||||
path: '/template/field/{fieldId}',
|
path: '/template/field/{fieldId}',
|
||||||
summary: 'Get template field',
|
summary: 'Get template field',
|
||||||
description:
|
description:
|
||||||
'Returns a single field. If you want to retrieve all the fields for a template, use the "Get Template" endpoint.',
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Returns a single field. If you want to retrieve all the fields for a template, use the "Get Template" endpoint.',
|
||||||
tags: ['Template Fields'],
|
tags: ['Template Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZGetFieldRequestSchema)
|
.input(ZGetFieldRequestSchema)
|
||||||
@@ -403,8 +418,10 @@ export const fieldRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/field/create-many',
|
path: '/template/field/create-many',
|
||||||
summary: 'Create template fields',
|
summary: 'Create template fields',
|
||||||
description: 'Create multiple fields for a template.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create multiple fields for a template.',
|
||||||
tags: ['Template Fields'],
|
tags: ['Template Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateTemplateFieldsRequestSchema)
|
.input(ZCreateTemplateFieldsRequestSchema)
|
||||||
@@ -445,8 +462,10 @@ export const fieldRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/field/update',
|
path: '/template/field/update',
|
||||||
summary: 'Update template field',
|
summary: 'Update template field',
|
||||||
description: 'Update a single field for a template.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update a single field for a template.',
|
||||||
tags: ['Template Fields'],
|
tags: ['Template Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZUpdateTemplateFieldRequestSchema)
|
.input(ZUpdateTemplateFieldRequestSchema)
|
||||||
@@ -485,8 +504,10 @@ export const fieldRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/field/update-many',
|
path: '/template/field/update-many',
|
||||||
summary: 'Update template fields',
|
summary: 'Update template fields',
|
||||||
description: 'Update multiple fields for a template.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update multiple fields for a template.',
|
||||||
tags: ['Template Fields'],
|
tags: ['Template Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZUpdateTemplateFieldsRequestSchema)
|
.input(ZUpdateTemplateFieldsRequestSchema)
|
||||||
@@ -523,7 +544,10 @@ export const fieldRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/field/delete',
|
path: '/template/field/delete',
|
||||||
summary: 'Delete template field',
|
summary: 'Delete template field',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Template Fields'],
|
tags: ['Template Fields'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZDeleteTemplateFieldRequestSchema)
|
.input(ZDeleteTemplateFieldRequestSchema)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
|
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
|
||||||
@@ -32,20 +31,6 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure
|
|||||||
userId,
|
userId,
|
||||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||||
}),
|
}),
|
||||||
include: {
|
|
||||||
organisationClaim: true,
|
|
||||||
subscription: true,
|
|
||||||
members: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
invites: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!organisation) {
|
if (!organisation) {
|
||||||
@@ -83,22 +68,6 @@ export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { organisationClaim } = organisation;
|
|
||||||
|
|
||||||
const numberOfCurrentMembers = organisation.members.length;
|
|
||||||
const numberOfCurrentInvites = organisation.invites.length;
|
|
||||||
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites - 1;
|
|
||||||
|
|
||||||
// Removing pending invites is a reducing operation, so we don't gate it on
|
|
||||||
// the subscription being present. Sync Stripe only when one exists.
|
|
||||||
if (organisation.subscription) {
|
|
||||||
await syncMemberCountWithStripeSeatPlan(
|
|
||||||
organisation.subscription,
|
|
||||||
organisationClaim,
|
|
||||||
totalMemberCountWithInvites,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.organisationMemberInvite.deleteMany({
|
await prisma.organisationMemberInvite.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
|
||||||
import {
|
import {
|
||||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||||
} from '@documenso/lib/constants/organisations';
|
} from '@documenso/lib/constants/organisations';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { jobs } from '@documenso/lib/jobs/client';
|
import { jobs } from '@documenso/lib/jobs/client';
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
isOrganisationRoleWithinUserHierarchy,
|
isOrganisationRoleWithinUserHierarchy,
|
||||||
} from '@documenso/lib/utils/organisations';
|
} from '@documenso/lib/utils/organisations';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@@ -59,8 +58,6 @@ export const deleteOrganisationMembers = async ({
|
|||||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||||
}),
|
}),
|
||||||
include: {
|
include: {
|
||||||
subscription: true,
|
|
||||||
organisationClaim: true,
|
|
||||||
teams: {
|
teams: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -75,14 +72,6 @@ export const deleteOrganisationMembers = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invites: {
|
|
||||||
where: {
|
|
||||||
status: OrganisationMemberInviteStatus.PENDING,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,8 +79,6 @@ export const deleteOrganisationMembers = async ({
|
|||||||
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { organisationClaim } = organisation;
|
|
||||||
|
|
||||||
const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id));
|
const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id));
|
||||||
|
|
||||||
const currentUserMember = organisation.members.find((member) => member.userId === userId);
|
const currentUserMember = organisation.members.find((member) => member.userId === userId);
|
||||||
@@ -129,15 +116,6 @@ export const deleteOrganisationMembers = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inviteCount = organisation.invites.length;
|
|
||||||
const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length;
|
|
||||||
|
|
||||||
// Removing members is a reducing operation, so we don't gate it on the
|
|
||||||
// subscription being present. Sync Stripe only when one exists.
|
|
||||||
if (organisation.subscription) {
|
|
||||||
await syncMemberCountWithStripeSeatPlan(organisation.subscription, organisationClaim, newMemberCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
const removedUserIds = membersToDelete.map((member) => member.userId);
|
const removedUserIds = membersToDelete.map((member) => member.userId);
|
||||||
const teamIds = organisation.teams.map((team) => team.id);
|
const teamIds = organisation.teams.map((team) => team.id);
|
||||||
|
|
||||||
@@ -184,6 +162,13 @@ export const deleteOrganisationMembers = async ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Members were removed — queue a seat sync to true the Stripe quantity down to
|
||||||
|
// the new count (no proration, no credit).
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'internal.sync-organisation-seats',
|
||||||
|
payload: { organisationId },
|
||||||
|
});
|
||||||
|
|
||||||
for (const member of membersToDelete) {
|
for (const member of membersToDelete) {
|
||||||
await jobs.triggerJob({
|
await jobs.triggerJob({
|
||||||
name: 'send.organisation-member-left.email',
|
name: 'send.organisation-member-left.email',
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { jobs } from '@documenso/lib/jobs/client';
|
import { jobs } from '@documenso/lib/jobs/client';
|
||||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import { ZLeaveOrganisationRequestSchema, ZLeaveOrganisationResponseSchema } from './leave-organisation.types';
|
import { ZLeaveOrganisationRequestSchema, ZLeaveOrganisationResponseSchema } from './leave-organisation.types';
|
||||||
@@ -24,26 +22,11 @@ export const leaveOrganisationRoute = authenticatedProcedure
|
|||||||
const organisation = await prisma.organisation.findFirst({
|
const organisation = await prisma.organisation.findFirst({
|
||||||
where: buildOrganisationWhereQuery({ organisationId, userId }),
|
where: buildOrganisationWhereQuery({ organisationId, userId }),
|
||||||
include: {
|
include: {
|
||||||
organisationClaim: true,
|
|
||||||
subscription: true,
|
|
||||||
teams: {
|
teams: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invites: {
|
|
||||||
where: {
|
|
||||||
status: OrganisationMemberInviteStatus.PENDING,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
members: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,17 +42,6 @@ export const leaveOrganisationRoute = authenticatedProcedure
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { organisationClaim } = organisation;
|
|
||||||
|
|
||||||
const inviteCount = organisation.invites.length;
|
|
||||||
const newMemberCount = organisation.members.length + inviteCount - 1;
|
|
||||||
|
|
||||||
// Leaving is a reducing operation, so we don't gate it on the subscription
|
|
||||||
// being present. Sync Stripe only when one exists.
|
|
||||||
if (organisation.subscription) {
|
|
||||||
await syncMemberCountWithStripeSeatPlan(organisation.subscription, organisationClaim, newMemberCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamIds = organisation.teams.map((team) => team.id);
|
const teamIds = organisation.teams.map((team) => team.id);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
@@ -101,6 +73,13 @@ export const leaveOrganisationRoute = authenticatedProcedure
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A member was removed — queue a seat sync to true the Stripe quantity down
|
||||||
|
// to the new count (no proration, no credit).
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'internal.sync-organisation-seats',
|
||||||
|
payload: { organisationId },
|
||||||
|
});
|
||||||
|
|
||||||
await jobs.triggerJob({
|
await jobs.triggerJob({
|
||||||
name: 'send.organisation-member-left.email',
|
name: 'send.organisation-member-left.email',
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ export const recipientRouter = router({
|
|||||||
path: '/document/recipient/{recipientId}',
|
path: '/document/recipient/{recipientId}',
|
||||||
summary: 'Get document recipient',
|
summary: 'Get document recipient',
|
||||||
description:
|
description:
|
||||||
'Returns a single recipient. If you want to retrieve all the recipients for a document, use the "Get Document" endpoint.',
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Returns a single recipient. If you want to retrieve all the recipients for a document, use the "Get Document" endpoint.',
|
||||||
tags: ['Document Recipients'],
|
tags: ['Document Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZGetRecipientRequestSchema)
|
.input(ZGetRecipientRequestSchema)
|
||||||
@@ -93,8 +94,10 @@ export const recipientRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/recipient/create',
|
path: '/document/recipient/create',
|
||||||
summary: 'Create document recipient',
|
summary: 'Create document recipient',
|
||||||
description: 'Create a single recipient for a document.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a single recipient for a document.',
|
||||||
tags: ['Document Recipients'],
|
tags: ['Document Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateDocumentRecipientRequestSchema)
|
.input(ZCreateDocumentRecipientRequestSchema)
|
||||||
@@ -132,8 +135,10 @@ export const recipientRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/recipient/create-many',
|
path: '/document/recipient/create-many',
|
||||||
summary: 'Create document recipients',
|
summary: 'Create document recipients',
|
||||||
description: 'Create multiple recipients for a document.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create multiple recipients for a document.',
|
||||||
tags: ['Document Recipients'],
|
tags: ['Document Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateDocumentRecipientsRequestSchema)
|
.input(ZCreateDocumentRecipientsRequestSchema)
|
||||||
@@ -169,8 +174,10 @@ export const recipientRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/recipient/update',
|
path: '/document/recipient/update',
|
||||||
summary: 'Update document recipient',
|
summary: 'Update document recipient',
|
||||||
description: 'Update a single recipient for a document.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update a single recipient for a document.',
|
||||||
tags: ['Document Recipients'],
|
tags: ['Document Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZUpdateDocumentRecipientRequestSchema)
|
.input(ZUpdateDocumentRecipientRequestSchema)
|
||||||
@@ -208,8 +215,10 @@ export const recipientRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/recipient/update-many',
|
path: '/document/recipient/update-many',
|
||||||
summary: 'Update document recipients',
|
summary: 'Update document recipients',
|
||||||
description: 'Update multiple recipients for a document.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update multiple recipients for a document.',
|
||||||
tags: ['Document Recipients'],
|
tags: ['Document Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZUpdateDocumentRecipientsRequestSchema)
|
.input(ZUpdateDocumentRecipientsRequestSchema)
|
||||||
@@ -245,7 +254,10 @@ export const recipientRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/recipient/delete',
|
path: '/document/recipient/delete',
|
||||||
summary: 'Delete document recipient',
|
summary: 'Delete document recipient',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Document Recipients'],
|
tags: ['Document Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZDeleteDocumentRecipientRequestSchema)
|
.input(ZDeleteDocumentRecipientRequestSchema)
|
||||||
@@ -315,8 +327,9 @@ export const recipientRouter = router({
|
|||||||
path: '/template/recipient/{recipientId}',
|
path: '/template/recipient/{recipientId}',
|
||||||
summary: 'Get template recipient',
|
summary: 'Get template recipient',
|
||||||
description:
|
description:
|
||||||
'Returns a single recipient. If you want to retrieve all the recipients for a template, use the "Get Template" endpoint.',
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Returns a single recipient. If you want to retrieve all the recipients for a template, use the "Get Template" endpoint.',
|
||||||
tags: ['Template Recipients'],
|
tags: ['Template Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZGetRecipientRequestSchema)
|
.input(ZGetRecipientRequestSchema)
|
||||||
@@ -348,8 +361,10 @@ export const recipientRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/recipient/create',
|
path: '/template/recipient/create',
|
||||||
summary: 'Create template recipient',
|
summary: 'Create template recipient',
|
||||||
description: 'Create a single recipient for a template.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a single recipient for a template.',
|
||||||
tags: ['Template Recipients'],
|
tags: ['Template Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateTemplateRecipientRequestSchema)
|
.input(ZCreateTemplateRecipientRequestSchema)
|
||||||
@@ -387,8 +402,10 @@ export const recipientRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/recipient/create-many',
|
path: '/template/recipient/create-many',
|
||||||
summary: 'Create template recipients',
|
summary: 'Create template recipients',
|
||||||
description: 'Create multiple recipients for a template.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create multiple recipients for a template.',
|
||||||
tags: ['Template Recipients'],
|
tags: ['Template Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateTemplateRecipientsRequestSchema)
|
.input(ZCreateTemplateRecipientsRequestSchema)
|
||||||
@@ -424,8 +441,10 @@ export const recipientRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/recipient/update',
|
path: '/template/recipient/update',
|
||||||
summary: 'Update template recipient',
|
summary: 'Update template recipient',
|
||||||
description: 'Update a single recipient for a template.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update a single recipient for a template.',
|
||||||
tags: ['Template Recipients'],
|
tags: ['Template Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZUpdateTemplateRecipientRequestSchema)
|
.input(ZUpdateTemplateRecipientRequestSchema)
|
||||||
@@ -463,8 +482,10 @@ export const recipientRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/recipient/update-many',
|
path: '/template/recipient/update-many',
|
||||||
summary: 'Update template recipients',
|
summary: 'Update template recipients',
|
||||||
description: 'Update multiple recipients for a template.',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Update multiple recipients for a template.',
|
||||||
tags: ['Template Recipients'],
|
tags: ['Template Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZUpdateTemplateRecipientsRequestSchema)
|
.input(ZUpdateTemplateRecipientsRequestSchema)
|
||||||
@@ -500,7 +521,10 @@ export const recipientRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/recipient/delete',
|
path: '/template/recipient/delete',
|
||||||
summary: 'Delete template recipient',
|
summary: 'Delete template recipient',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Template Recipients'],
|
tags: ['Template Recipients'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZDeleteTemplateRecipientRequestSchema)
|
.input(ZDeleteTemplateRecipientRequestSchema)
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ export const getTemplatesByIdsMeta: TrpcRouteMeta = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/get-many',
|
path: '/template/get-many',
|
||||||
summary: 'Get multiple templates',
|
summary: 'Get multiple templates',
|
||||||
description: 'Retrieve multiple templates by their IDs',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Retrieve multiple templates by their IDs',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,10 @@ export const templateRouter = router({
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/template',
|
path: '/template',
|
||||||
summary: 'Find templates',
|
summary: 'Find templates',
|
||||||
description: 'Find templates based on a search criteria',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Find templates based on a search criteria',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZFindTemplatesRequestSchema)
|
.input(ZFindTemplatesRequestSchema)
|
||||||
@@ -201,7 +203,10 @@ export const templateRouter = router({
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/template/{templateId}',
|
path: '/template/{templateId}',
|
||||||
summary: 'Get template',
|
summary: 'Get template',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZGetTemplateByIdRequestSchema)
|
.input(ZGetTemplateByIdRequestSchema)
|
||||||
@@ -245,8 +250,10 @@ export const templateRouter = router({
|
|||||||
path: '/template/create',
|
path: '/template/create',
|
||||||
contentTypes: ['multipart/form-data'],
|
contentTypes: ['multipart/form-data'],
|
||||||
summary: 'Create template',
|
summary: 'Create template',
|
||||||
description: 'Create a new template',
|
description:
|
||||||
|
'Create a new template. Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateTemplateMutationSchema)
|
.input(ZCreateTemplateMutationSchema)
|
||||||
@@ -334,8 +341,9 @@ export const templateRouter = router({
|
|||||||
path: '/template/create/beta',
|
path: '/template/create/beta',
|
||||||
summary: 'Create template',
|
summary: 'Create template',
|
||||||
description:
|
description:
|
||||||
'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.',
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateTemplateV2RequestSchema)
|
.input(ZCreateTemplateV2RequestSchema)
|
||||||
@@ -418,7 +426,10 @@ export const templateRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/update',
|
path: '/template/update',
|
||||||
summary: 'Update template',
|
summary: 'Update template',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZUpdateTemplateRequestSchema)
|
.input(ZUpdateTemplateRequestSchema)
|
||||||
@@ -461,7 +472,10 @@ export const templateRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/duplicate',
|
path: '/template/duplicate',
|
||||||
summary: 'Duplicate template',
|
summary: 'Duplicate template',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZDuplicateTemplateMutationSchema)
|
.input(ZDuplicateTemplateMutationSchema)
|
||||||
@@ -497,7 +511,10 @@ export const templateRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/delete',
|
path: '/template/delete',
|
||||||
summary: 'Delete template',
|
summary: 'Delete template',
|
||||||
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide.',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZDeleteTemplateMutationSchema)
|
.input(ZDeleteTemplateMutationSchema)
|
||||||
@@ -534,8 +551,10 @@ export const templateRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/use',
|
path: '/template/use',
|
||||||
summary: 'Use template',
|
summary: 'Use template',
|
||||||
description: 'Use the template to create a document',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Use the template to create a document',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateDocumentFromTemplateRequestSchema)
|
.input(ZCreateDocumentFromTemplateRequestSchema)
|
||||||
@@ -687,8 +706,10 @@ export const templateRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/direct/create',
|
path: '/template/direct/create',
|
||||||
summary: 'Create direct link',
|
summary: 'Create direct link',
|
||||||
description: 'Create a direct link for a template',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Create a direct link for a template',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZCreateTemplateDirectLinkRequestSchema)
|
.input(ZCreateTemplateDirectLinkRequestSchema)
|
||||||
@@ -743,8 +764,10 @@ export const templateRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/direct/delete',
|
path: '/template/direct/delete',
|
||||||
summary: 'Delete direct link',
|
summary: 'Delete direct link',
|
||||||
description: 'Delete a direct link for a template',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Delete a direct link for a template',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZDeleteTemplateDirectLinkRequestSchema)
|
.input(ZDeleteTemplateDirectLinkRequestSchema)
|
||||||
@@ -775,8 +798,10 @@ export const templateRouter = router({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/template/direct/toggle',
|
path: '/template/direct/toggle',
|
||||||
summary: 'Toggle direct link',
|
summary: 'Toggle direct link',
|
||||||
description: 'Enable or disable a direct link for a template',
|
description:
|
||||||
|
'Deprecated: this endpoint is being replaced by the Envelope API. See https://docs.documenso.com/docs/developers/api/migrate-to-envelopes for the migration guide. Enable or disable a direct link for a template',
|
||||||
tags: ['Template'],
|
tags: ['Template'],
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(ZToggleTemplateDirectLinkRequestSchema)
|
.input(ZToggleTemplateDirectLinkRequestSchema)
|
||||||
|
|||||||
Reference in New Issue
Block a user