Compare commits

..
Author SHA1 Message Date
Catalin Pit a39e034876 feat: hide the reject option when the document disallows rejection
Hides the reject dialog on the V2 signing page sidebar and mobile menu,
the embed V1 signing page and the multi-sign embed view when the
document's allowDocumentRejection is false. In embeds the reject option
is shown only when both the embed's allowDocumentRejection option and
the document setting allow it, and the embed V1 footer layout follows the
same condition. Exposes the field in the multi-sign document response so
the embed view can read it.

Documents the relationship between the embed's allowDocumentRejection
option and the document setting in the iframe and embedded editor docs,
and in the API description of the field.

Part of #2561
2026-09-14 16:45:27 +03:00
Catalin Pit 5ab94b86fd Merge branch 'main' into feat/org-team-allow-document-rejection-setting 2026-09-11 14:46:41 +03:00
Catalin Pit 2140d132d7 feat: persist and enforce the allow document rejection setting
Accepts allowDocumentRejection in the organisation and team settings
update routes, adds it to the default organisation (true) and team
(inherit) settings, and copies the merged value into the document meta
when a document or template is created via extractDerivedDocumentMeta.
Documents created from a template take the template's value, with an
optional override on envelope.use.

Exposes the field in the recipient signing response and refuses
rejectDocumentWithToken requests for documents that disallow rejection,
since the endpoint can be called directly without the UI.

Extends the document preferences and envelope settings e2e tests to
cover inheritance into the team settings and new documents, and
persistence of the per-document value from the editor.
2026-09-11 14:28:15 +03:00
Catalin Pit d91a3e3828 feat: add allow document rejection setting to document preferences
Adds an allowDocumentRejection column to organisation settings (default
true), team settings (nullable, inherits from the organisation) and
document meta (default true), with the migration.

Adds the "Allow Document Rejection" field to the organisation and team
document preferences form, wires it through the settings pages, and lists
it in the reset-to-defaults dialog and the admin settings section. Adds a
per-document Yes/No field to the envelope editor settings dialog.

Exposes the field on the document meta create/update schemas and the
document, envelope, template and editor envelope response schemas, and
hides the reject dialog on the V1 signing page when the document
disallows rejection.
2026-09-11 09:38:44 +03:00
157 changed files with 5141 additions and 12011 deletions
-2
View File
@@ -80,8 +80,6 @@ NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
# OPTIONAL: Reason to embed in PDF signatures. Defaults to "Signed by Documenso".
NEXT_PRIVATE_SIGNING_REASON=
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
@@ -15,8 +15,6 @@ This guide provides a comprehensive troubleshooting matrix for the standard erro
| `INVALID_REQUEST` | The overall request is malformed or invalid. | Review your API call parameters, including the URL, query parameters, and headers. Correct the request syntax. |
| `RECIPIENT_EXPIRED` | The signing link or recipient access has expired. | Generate and resend a new invitation to the affected recipient. |
| `LIMIT_EXCEEDED` | Your account usage quota has been exceeded. | Check your current plan limits. Upgrade your subscription or wait until your billing cycle renews. |
| `MISSING_ENV_VAR` | A required environment variable is not configured on the server (500). | Primarily affects self-hosted instances: set the environment variable named in the error message and restart. On Documenso Cloud, contact support. |
| `MISSING_SIGNATURE_FIELD` | A signer has no signature field placed on the document (400). Returned when distributing an envelope. | Add at least one signature field for every recipient with a signing role before calling `/envelope/distribute`. |
| `NOT_FOUND` | The requested resource could not be found (404). | Verify the resource ID (envelope, document, webhook) passed in the URL. Ensure the resource has not been deleted. |
| `NOT_IMPLEMENTED` | The requested feature is not currently supported by the server. | Consult the API documentation to verify available methods. Do not use this endpoint at this time. |
| `NOT_SETUP` | The required configuration for this action is incomplete. | Access your account or integration settings and complete the necessary configuration before retrying. |
@@ -39,28 +37,7 @@ The following errors occur when attempting to perform actions on an envelope tha
| `ENVELOPE_DRAFT` | The action cannot be performed because the envelope is still in a draft state. | Finalize the envelope configuration and transition it to the `PENDING` (sent) state before attempting this operation. |
| `ENVELOPE_COMPLETED` | The action cannot be performed because the envelope is already completed. | No further modifications (e.g., adding signers, modifying documents) can be made to an envelope once the signing process is finished. |
| `ENVELOPE_REJECTED` | The action cannot be performed because the envelope was rejected by a recipient. | The signing flow is permanently halted. Create a new envelope if you wish to resubmit the document. |
| `ENVELOPE_CANCELLED` | The action cannot be performed because the envelope was cancelled (400). | Create a new envelope if you need to restart the signing process. |
| `ENVELOPE_LEGACY` | The action cannot be performed because the envelope uses an obsolete format. | This envelope was created with a legacy version of the system. Recreate the envelope using the current API version to interact with it. |
| `ENVELOPE_TSP_LOCKED` | An AES/QES envelope cannot be modified after it leaves the draft state (400). | Make changes while the envelope is in `DRAFT`, or create a new envelope. |
## CSC Signing Errors
These errors apply to Cloud Signature Consortium (CSC) signing flows.
| Error Code | Description | Recommended Action |
| :--- | :--- | :--- |
| `CSC_INSTANCE_MODE_MISMATCH` | The requested signature level does not match the instance's CSC mode (400). | Use the signature level supported by the instance's signing configuration. |
| `CSC_UNLICENSED` | CSC signing is not licensed for this instance (403). | Enable the CSC signing license before retrying. |
| `CSC_PROVIDER_INFO_FAILED` | The CSC provider's discovery request failed or returned unusable information (500). | Check the provider URL, availability, and OAuth configuration. |
| `CSC_PROVIDER_NO_TSA` | A timestamp authority is unavailable or unusable for CSC signing (500). | Configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` and verify provider timestamp access. |
| `CSC_CREDENTIAL_LIST_EMPTY` | The CSC provider returned no signing credentials for the authenticated user (400). | Enrol a signing credential with the provider, then authenticate again. |
| `CSC_CERT_INVALID` | The selected signing certificate is missing, invalid, or outside its validity period (400). | Select or renew a valid certificate, then authenticate again. |
| `CSC_ALGORITHM_REFUSED` | The signing credential uses an unsupported key or digest algorithm (400). | Select a credential that satisfies the instance's CSC algorithm policy. |
| `CSC_SAD_EXPIRED_PRE_SIGN` | The signature activation data is missing, expired, or unreadable before signing (400). | Repeat the credential authorization flow. |
| `CSC_TSP_TIMEOUT` | The trust service provider did not complete the signing request before the timeout (408). | Retry the signing request after checking provider availability. |
| `CSC_EMBED_FAILED` | The returned CSC signature could not be embedded into the envelope items (400). | Restart the signing attempt. If it fails again, contact support. |
| `CSC_BASE_DOCUMENT_MUTATED` | The document changed between signature preparation and signing (500). | Restart signing from the current envelope state. |
| `CSC_REQUEST_FAILED` | A CSC provider request failed without a more specific CSC error (500). | Check provider availability and configuration, then retry. |
## See Also
+88 -193
View File
@@ -6,8 +6,6 @@ description: Add signature and form fields to documents via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
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).
@@ -21,13 +19,13 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| `secondaryId` | string | Secondary identifier for audit logs |
| `type` | string | Field type (see [Field Types](#field-types)) |
| `recipientId` | number | ID of the recipient assigned to this field |
| `envelopeId` | string | ID of the parent envelope |
| `envelopeId` | number | ID of the parent envelope |
| `envelopeItemId` | string | ID of the PDF item the field is placed on |
| `page` | number | Page number (1-indexed) |
| `positionX` | string | X coordinate as percentage (0-100), a decimal serialized as a string |
| `positionY` | string | Y coordinate as percentage (0-100), a decimal serialized as a string |
| `width` | string | Width as percentage of page (0-100), a decimal serialized as a string |
| `height` | string | Height as percentage of page (0-100), a decimal serialized as a string |
| `positionX` | number | X coordinate as percentage (0-100) |
| `positionY` | number | Y coordinate as percentage (0-100) |
| `width` | number | Width as percentage of page (0-100) |
| `height` | number | Height as percentage of page (0-100) |
| `customText` | string | Value entered by the recipient |
| `inserted` | boolean | Whether the field has been completed |
| `fieldMeta` | object \| null | Type-specific configuration options |
@@ -40,19 +38,18 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
"secondaryId": "field_abc123",
"type": "SIGNATURE",
"recipientId": 123,
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"envelopeId": 789,
"envelopeItemId": "envelope_item_xyz",
"page": 1,
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5,
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"required": true,
"overflow": "auto"
"required": true
}
}
```
@@ -64,7 +61,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| Type | Description | Auto-filled |
| ---------------- | ----------------------------------------- | ----------- |
| `SIGNATURE` | Drawn, typed, or uploaded signature | No |
| `FREE_SIGNATURE` | Legacy free-form signature. Accepted by the v2 create schema but rejected by the v1 API and unsupported in the signing UI — avoid in new integrations | No |
| `FREE_SIGNATURE` | Unrestricted signature without validation | No |
| `INITIALS` | Recipient's initials | No |
| `NAME` | Recipient's full name | Yes |
| `EMAIL` | Recipient's email address | Yes |
@@ -137,12 +134,10 @@ POST /envelope/field/create-many
### Request Body
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ------------------------------- |
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field configurations |
Each entry in `data` requires a `type`, a `recipientId`, and a position — either explicit coordinates (`page`, `positionX`, `positionY`, `width`, `height`) or a [text placeholder](#placeholder-based-field-positioning) (`placeholder` with optional `width`, `height`, and `matchAll`). Optional per-entry properties: `envelopeItemId` (which PDF in the envelope to place the field on; defaults to the first item) and `fieldMeta`.
| Field | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------- |
| `documentId`| number | Yes | The document ID |
| `fields` | array | Yes | Array of field configurations |
### Code Examples
@@ -153,32 +148,32 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/create-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
"documentId": 123,
"fields": [
{
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 80,
"pageNumber": 1,
"pageX": 10,
"pageY": 80,
"width": 30,
"height": 5
},
{
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": 50,
"positionY": 80,
"pageNumber": 1,
"pageX": 50,
"pageY": 80,
"width": 20,
"height": 3
},
{
"type": "TEXT",
"recipientId": 456,
"page": 1,
"positionX": 10,
"positionY": 70,
"pageNumber": 1,
"pageX": 10,
"pageY": 70,
"width": 40,
"height": 4,
"fieldMeta": {
@@ -204,32 +199,32 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
documentId: 123,
fields: [
{
type: 'SIGNATURE',
recipientId: 456,
page: 1,
positionX: 10,
positionY: 80,
pageNumber: 1,
pageX: 10,
pageY: 80,
width: 30,
height: 5,
},
{
type: 'DATE',
recipientId: 456,
page: 1,
positionX: 50,
positionY: 80,
pageNumber: 1,
pageX: 50,
pageY: 80,
width: 20,
height: 3,
},
{
type: 'TEXT',
recipientId: 456,
page: 1,
positionX: 10,
positionY: 70,
pageNumber: 1,
pageX: 10,
pageY: 70,
width: 40,
height: 4,
fieldMeta: {
@@ -244,8 +239,8 @@ const response = await fetch(
}
);
const { data } = await response.json();
console.log(`Created ${data.length} fields`);
const { fields } = await response.json();
console.log(`Created ${fields.length} fields`);
````
</Tab>
@@ -255,68 +250,36 @@ console.log(`Created ${data.length} fields`);
```json
{
"data": [
"fields": [
{
"id": 101,
"secondaryId": "field_abc123",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": "10",
"positionY": "80",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"fontSize": 18,
"overflow": "auto"
}
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5
},
{
"id": 102,
"secondaryId": "field_def456",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": "50",
"positionY": "80",
"width": "20",
"height": "3",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
"positionX": 50,
"positionY": 80,
"width": 20,
"height": 3
},
{
"id": 103,
"secondaryId": "field_ghi789",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "TEXT",
"recipientId": 456,
"page": 1,
"positionX": "10",
"positionY": "70",
"width": "40",
"height": "4",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "text",
"label": "Job Title",
"placeholder": "Enter your job title",
"required": true
}
"positionX": 10,
"positionY": 70,
"width": 40,
"height": 4
}
]
}
@@ -336,10 +299,8 @@ POST /envelope/field/update-many
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ----------------------------- |
| `envelopeId` | string | Yes | The envelope ID |
| `data` | array | Yes | Array of field update objects |
Each entry in `data` requires the field `id` and `type`. Position properties (`page`, `positionX`, `positionY`, `width`, `height`), `envelopeItemId`, and `fieldMeta` are optional — only supplied values are updated. Placeholder positioning is not supported when updating; use coordinates.
| `documentId` | number | Yes | The document ID |
| `fields` | array | Yes | Array of field update objects |
### Code Examples
@@ -350,17 +311,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/update-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
"documentId": 123,
"fields": [
{
"id": 101,
"type": "SIGNATURE",
"positionY": 85
"pageY": 85
},
{
"id": 102,
"type": "DATE",
"positionY": 85
"pageY": 85
}
]
}'
@@ -377,16 +338,16 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{ id: 101, type: 'SIGNATURE', positionY: 85 },
{ id: 102, type: 'DATE', positionY: 85 },
documentId: 123,
fields: [
{ id: 101, type: 'SIGNATURE', pageY: 85 },
{ id: 102, type: 'DATE', pageY: 85 },
],
}),
}
);
const { data } = await response.json();
const { fields } = await response.json();
````
</Tab>
@@ -396,48 +357,9 @@ const { data } = await response.json();
```json
{
"data": [
{
"id": 101,
"secondaryId": "field_abc123",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "SIGNATURE",
"recipientId": 456,
"page": 1,
"positionX": "10",
"positionY": "85",
"width": "30",
"height": "5",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "signature",
"fontSize": 18,
"overflow": "auto"
}
},
{
"id": 102,
"secondaryId": "field_def456",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"type": "DATE",
"recipientId": 456,
"page": 1,
"positionX": "50",
"positionY": "85",
"width": "20",
"height": "3",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
}
"fields": [
{ "id": 101, "type": "SIGNATURE", "positionY": 85 },
{ "id": 102, "type": "DATE", "positionY": 85 }
]
}
````
@@ -521,8 +443,8 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
(0,0) ─────────────────────────── (100,0)
│ │
│ ┌─────────┐ │
│ │ Field │ (positionX: 10, │
│ │ │ positionY: 20, │
│ │ Field │ (pageX: 10, │
│ │ │ pageY: 20, │
│ └─────────┘ width: 30, │
│ height: 5) │
│ │
@@ -535,9 +457,9 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
const field = {
type: 'SIGNATURE',
recipientId: 123,
page: 1,
positionX: 60, // 60% from left
positionY: 85, // 85% from top (near bottom)
pageNumber: 1,
pageX: 60, // 60% from left
pageY: 85, // 85% from top (near bottom)
width: 30, // 30% of page width
height: 8, // 8% of page height
};
@@ -557,33 +479,6 @@ This approach is useful when generating PDFs programmatically or using templates
See the [PDF Placeholders](/docs/users/documents/advanced/pdf-placeholders) guide for the full placeholder format reference, including supported field types, recipient identifiers, and field options.
### Placeholder Positioning via the API
`POST /envelope/field/create-many` accepts a placeholder position in place of coordinates. Instead of `page`, `positionX`, `positionY`, `width`, and `height`, pass:
| Field | Type | Required | Description |
| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------ |
| `placeholder` | string | Yes | Text to search for in the PDF (e.g. `{{name}}`). The field is placed at the bounding box of the first match. |
| `width` | number | No | Override the field width. Defaults to the width of the matched text. |
| `height` | number | No | Override the field height. Defaults to the height of the matched text. |
| `matchAll` | boolean | No | Create a field at every occurrence of the placeholder instead of only the first. |
```json
{
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"type": "SIGNATURE",
"recipientId": 456,
"placeholder": "{{signature}}",
"matchAll": true
}
]
}
```
`POST /envelope/field/update-many` does not accept placeholders — field updates are coordinate-only.
---
## Field Meta Options
@@ -748,15 +643,15 @@ All field types support these base options:
Create a document with a signature block containing multiple field types:
```typescript
async function addSignatureBlock(envelopeId: string, recipientId: number) {
const data = [
async function addSignatureBlock(documentId: number, recipientId: number) {
const fields = [
// Signature
{
type: 'SIGNATURE',
recipientId,
page: 1,
positionX: 10,
positionY: 80,
pageNumber: 1,
pageX: 10,
pageY: 80,
width: 30,
height: 8,
fieldMeta: {
@@ -768,9 +663,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
{
type: 'NAME',
recipientId,
page: 1,
positionX: 10,
positionY: 90,
pageNumber: 1,
pageX: 10,
pageY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -782,9 +677,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
{
type: 'DATE',
recipientId,
page: 1,
positionX: 50,
positionY: 80,
pageNumber: 1,
pageX: 50,
pageY: 80,
width: 20,
height: 4,
fieldMeta: {
@@ -796,9 +691,9 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
{
type: 'TEXT',
recipientId,
page: 1,
positionX: 50,
positionY: 90,
pageNumber: 1,
pageX: 50,
pageY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -815,7 +710,7 @@ async function addSignatureBlock(envelopeId: string, recipientId: number) {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId, data }),
body: JSON.stringify({ documentId, fields }),
});
return response.json();
@@ -60,8 +60,8 @@ Authorization: api_xxxxxxxxxxxxxxxx
href="/docs/developers/api/templates"
/>
<Card
title="Team-scoped access"
description="Use team-scoped API tokens with envelope endpoints."
title="Teams"
description="Manage teams and team members."
href="/docs/developers/api/teams"
/>
</Cards>
@@ -6,8 +6,6 @@ description: Add and manage envelope recipients via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
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).
@@ -18,7 +16,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
```json
{
"id": 123,
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeId": "clu1abc2def3ghi4jkl",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -136,7 +134,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/create-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeId": "clu1abc2def3ghi4jkl",
"data": [
{
"email": "signer@example.com",
@@ -166,7 +164,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
envelopeId: 'clu1abc2def3ghi4jkl',
data: [
{
email: 'signer@example.com',
@@ -198,7 +196,7 @@ const { data: recipients } = await response.json();
"data": [
{
"id": 789,
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeId": "clu1abc2def3ghi4jkl",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -211,7 +209,7 @@ const { data: recipients } = await response.json();
},
{
"id": 790,
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeId": "clu1abc2def3ghi4jkl",
"email": "approver@example.com",
"name": "Jane Smith",
"role": "APPROVER",
@@ -264,7 +262,7 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/recipient/update-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeId": "clu1abc2def3ghi4jkl",
"data": [
{
"id": 789,
@@ -286,7 +284,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
envelopeId: 'clu1abc2def3ghi4jkl',
data: [
{
id: 789,
@@ -389,7 +387,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
envelopeId: 'clu1abc2def3ghi4jkl',
data: [
{
email: 'approver@example.com',
@@ -464,7 +462,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'envelope_abcdefhiklmnorst',
envelopeId: 'clu1abc2def3ghi4jkl',
data: [
{
email: 'signer@example.com',
+42 -29
View File
@@ -1,29 +1,44 @@
---
title: Team-Scoped API Access
description: Use team-scoped API tokens with document and template envelopes.
title: Teams API
description: Manage team resources, documents, and templates with team-scoped API tokens.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
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).
</Callout>
## Team Context
## Team Object
<Callout type="info">
The V2 REST API does not expose `/team/*` endpoints. Create and manage teams, members, and team
settings in the Documenso web application. This page explains how a team-scoped token applies
that team context to supported API resources.
</Callout>
A team object contains the following properties:
The API resolves the team from your token. You do not pass a team ID when creating, listing, or
using envelopes. The token's team ID determines which resources the request can access.
| Property | Type | Description |
| ----------------- | -------------- | --------------------------------------------------- |
| `id` | number | Unique team identifier |
| `name` | string | Team display name |
| `url` | string | Unique team URL slug |
| `createdAt` | string | ISO 8601 timestamp |
| `avatarImageId` | string \| null | ID of the team's avatar image |
| `organisationId` | string | ID of the parent organisation |
| `currentTeamRole` | string | Your role in the team: `ADMIN`, `MANAGER`, `MEMBER` |
### Example Team Object
```json
{
"id": 123,
"name": "Engineering",
"url": "engineering",
"createdAt": "2025-01-15T10:30:00.000Z",
"avatarImageId": null,
"organisationId": "org_abc123",
"currentTeamRole": "ADMIN"
}
```
## Team-Scoped API Tokens
@@ -141,26 +156,26 @@ Retrieve all documents belonging to the team:
<Tab value="curl">
```bash
# List all team documents
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT" \
curl -X GET "https://app.documenso.com/api/v2/envelope" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
# Filter by status
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING" \
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT', {
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
},
});
const { data, count } = await response.json();
console.log(`Found ${count} team documents`);
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} team documents`);
````
</Tab>
@@ -175,11 +190,10 @@ Templates created with a team token are shared across the team.
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
curl -X POST "https://app.documenso.com/api/v2/template/create" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"type": "TEMPLATE",
"title": "NDA Template",
"recipients": [
{
@@ -209,7 +223,6 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
const form = new FormData();
const payload = {
type: 'TEMPLATE',
title: 'NDA Template',
recipients: [
{
@@ -236,7 +249,7 @@ form.append('files', fs.createReadStream('./nda-template.pdf'), {
contentType: 'application/pdf',
});
const response = await fetch('https://app.documenso.com/api/v2/envelope/create', {
const response = await fetch('https://app.documenso.com/api/v2/template/create', {
method: 'POST',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -244,8 +257,8 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/create',
body: form,
});
const { id } = await response.json();
console.log('Created team template envelope:', id);
const template = await response.json();
console.log('Created team template:', template.id);
````
</Tab>
</Tabs>
@@ -255,14 +268,14 @@ console.log('Created team template envelope:', id);
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X GET "https://app.documenso.com/api/v2/envelope?type=TEMPLATE" \
curl -X GET "https://app.documenso.com/api/v2/template" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=TEMPLATE', {
const response = await fetch('https://app.documenso.com/api/v2/template', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -317,19 +330,19 @@ const SALES_TEAM_TOKEN = process.env.SALES_TEAM_API_TOKEN;
const LEGAL_TEAM_TOKEN = process.env.LEGAL_TEAM_API_TOKEN;
// Get pending documents from sales team
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING', {
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=PENDING', {
headers: { Authorization: SALES_TEAM_TOKEN },
});
const salesDocs = await salesResponse.json();
// Get completed documents from legal team
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=COMPLETED', {
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?status=COMPLETED', {
headers: { Authorization: LEGAL_TEAM_TOKEN },
});
const legalDocs = await legalResponse.json();
console.log(`Sales team: ${salesDocs.count} pending`);
console.log(`Legal team: ${legalDocs.count} completed`);
console.log(`Sales team: ${salesDocs.pagination.totalItems} pending`);
console.log(`Legal team: ${legalDocs.pagination.totalItems} completed`);
```
## Error Responses
@@ -13,194 +13,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
see the [OpenAPI Reference](https://openapi.documenso.com).
</Callout>
## Use a Template Envelope
New integrations should create a document from a template envelope with the Envelope API.
```
POST /envelope/use
Content-Type: multipart/form-data
```
The request uses `multipart/form-data`:
| Part | Type | Required | Description |
| --------- | ------- | -------- | ------------------------------------------------------------------ |
| `payload` | JSON | Yes | Template envelope ID, recipient details, and document settings |
| `files` | File(s) | No | Replacement PDFs referenced by entries in `customDocumentData` |
### Payload Schema
| Field | Type | Required | Description |
| -------------------- | ------- | -------- | ------------------------------------------------------------------------ |
| `envelopeId` | string | Yes | ID of the template envelope |
| `externalId` | string | No | Your identifier for the created document envelope |
| `recipients` | array | No | Recipient details mapped to recipients in the template |
| `distributeDocument` | boolean | No | If `true`, create the document as pending and distribute it |
| `customDocumentData` | array | No | Maps uploaded replacement PDFs to template envelope items |
| `folderId` | string | No | Folder in which to create the document |
| `prefillFields` | array | No | Field values to prefill before distribution |
| `override` | object | No | Template values to override for the created document |
| `attachments` | array | No | Link attachments to add to the document |
| `formValues` | object | No | PDF form values to apply |
Each recipient entry accepts the following fields:
| Field | Type | Required | Description |
| -------------- | ------- | -------- | -------------------------------------------- |
| `id` | number | Yes | Recipient ID from the template envelope |
| `email` | string | Yes | Recipient email address |
| `name` | string | No | Recipient display name |
| `signingOrder` | number | No | Recipient position in sequential signing |
Each `customDocumentData` entry maps an uploaded file to a template item:
| Field | Type | Required | Description |
| ---------------- | ---------------- | -------- | --------------------------------------------------------------- |
| `identifier` | string \| number | Yes | Uploaded filename or zero-based file index |
| `envelopeItemId` | string | Yes | Template envelope item whose PDF the uploaded file replaces |
### Code Examples
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/use" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-F 'payload={
"envelopeId": "envelope_template123",
"externalId": "contract-2025-001",
"recipients": [
{
"id": 1,
"email": "john.doe@example.com",
"name": "John Doe"
}
],
"prefillFields": [
{
"id": 101,
"type": "text",
"value": "Senior Software Engineer"
}
],
"distributeDocument": false
}'
```
</Tab>
<Tab value="TypeScript">
```typescript
const form = new FormData();
form.append(
'payload',
JSON.stringify({
envelopeId: 'envelope_template123',
externalId: 'contract-2025-001',
recipients: [
{
id: 1,
email: 'john.doe@example.com',
name: 'John Doe',
},
],
prefillFields: [
{
id: 101,
type: 'text',
value: 'Senior Software Engineer',
},
],
distributeDocument: false,
}),
);
const response = await fetch('https://app.documenso.com/api/v2/envelope/use', {
method: 'POST',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
},
body: form,
});
const document = await response.json();
console.log('Created document envelope:', document.id);
```
</Tab>
</Tabs>
### Response
```json
{
"id": "envelope_document123",
"recipients": [
{
"id": 1,
"name": "John Doe",
"email": "john.doe@example.com",
"token": "recipient_token",
"role": "SIGNER",
"signingOrder": 1,
"signingUrl": "https://app.documenso.com/sign/recipient_token"
}
]
}
```
### Distribute the Created Envelope
If you leave `distributeDocument` unset or set it to `false`, distribute the created document with
`POST /envelope/distribute`. Its response confirms delivery and includes each recipient's signing URL.
```typescript
const distributionResponse = await fetch(
'https://app.documenso.com/api/v2/envelope/distribute',
{
method: 'POST',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.id,
}),
},
);
const distribution = await distributionResponse.json();
console.log('Signing URL:', distribution.recipients[0].signingUrl);
```
```json
{
"success": true,
"id": "envelope_document123",
"recipients": [
{
"id": 1,
"name": "John Doe",
"email": "john.doe@example.com",
"token": "recipient_token",
"role": "SIGNER",
"signingOrder": 1,
"signingUrl": "https://app.documenso.com/sign/recipient_token"
}
]
}
```
---
## Deprecated Template Endpoint Reference
<Callout type="warn">
Every `/template/*` endpoint below is deprecated. Use the Envelope API for new integrations and
follow [Migrating to Envelopes](/docs/developers/api/migrate-to-envelopes) to replace existing calls.
The legacy reference remains here to support migrations.
</Callout>
## Legacy Template Object
## Template Object
A template object contains the following properties:
@@ -278,7 +91,7 @@ A template object contains the following properties:
}
```
## List Templates (Deprecated)
## List Templates
Retrieve a paginated list of templates.
@@ -326,8 +139,8 @@ const response = await fetch(`${BASE_URL}/template`, {
},
});
const { data, count } = await response.json();
console.log(`Found ${count} templates`);
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} templates`);
// Filter by type
const privateResponse = await fetch(
@@ -368,16 +181,18 @@ const privateTemplates = await privateResponse.json();
]
}
],
"count": 25,
"currentPage": 1,
"perPage": 10,
"totalPages": 3
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 3,
"totalItems": 25
}
}
```
---
## Get Template (Deprecated)
## Get Template
Retrieve a single template by ID.
@@ -423,9 +238,9 @@ Returns the full template object including recipients, fields, and metadata.
---
## Create Document from Template (Deprecated)
## Create Document from Template
Create a new document using the deprecated template endpoint.
Create a new document using a template. This is the primary way to use templates programmatically.
<Callout type="info">
This endpoint does not support [PDF placeholder parsing](/docs/users/documents/advanced/pdf-placeholders). Use `POST /envelope/create` for placeholder-based field positioning.
@@ -600,57 +415,32 @@ const prefilledDocument = await prefillResponse.json();
### Response
The endpoint returns the full legacy document object. The selected fields below show both the numeric
legacy `id` and canonical `envelopeId`. Recipient entries do not include a `signingUrl`.
Returns the created document object with recipients and signing URLs.
```json
{
"id": 789,
"envelopeId": "envelope_xyz789",
"id": "envelope_xyz789",
"type": "DOCUMENT",
"status": "PENDING",
"source": "TEMPLATE",
"title": "Employment Contract",
"source": "TEMPLATE",
"externalId": "contract-2025-001",
"recipients": [
{
"id": 1,
"envelopeId": "envelope_xyz789",
"documentId": 789,
"templateId": null,
"email": "john.doe@example.com",
"name": "John Doe",
"role": "SIGNER",
"signingStatus": "NOT_SIGNED",
"signingOrder": 1
"signingUrl": "https://app.documenso.com/sign/abc123"
}
]
}
```
To send a document created with `distributeDocument: false` and receive signing links, call
`POST /envelope/distribute` with its `envelopeId`:
```typescript
const document = await response.json();
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.envelopeId,
}),
});
const distribution = await distributionResponse.json();
console.log('Signing URL:', distribution.recipients[0].signingUrl);
```
````
---
## Override Template Settings (Deprecated)
## Override Template Settings
When creating a document from a template, you can override various settings:
@@ -698,7 +488,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Prefill Fields (Deprecated)
## Prefill Fields
Prefill field values when creating a document from a template. This is useful for populating known data before sending.
@@ -787,7 +577,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Update Template (Deprecated)
## Update Template
Update a template's properties.
@@ -853,7 +643,7 @@ const template = await response.json();
---
## Duplicate Template (Deprecated)
## Duplicate Template
Create a copy of an existing template.
@@ -905,7 +695,7 @@ console.log('New template ID:', duplicatedTemplate.id);
---
## Delete Template (Deprecated)
## Delete Template
Delete a template.
@@ -964,7 +754,7 @@ const { success } = await response.json();
---
## Direct Link Templates (Deprecated)
## Direct Link Templates
Direct link templates allow recipients to create and sign documents without requiring you to explicitly create each document. When a recipient visits the direct link, a new document is automatically created from the template.
@@ -1108,7 +898,7 @@ const { success } = await response.json();
---
## Custom Document Data (Deprecated)
## Custom Document Data
When creating a document from a template, you can replace the template's PDF with a custom PDF by using the `customDocumentData` parameter. This is useful when you need to generate the PDF dynamically while reusing the template's recipient and field configuration.
@@ -1123,7 +913,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Template Types (Legacy)
## Template Types
| Type | Description |
| --------- | ------------------------------------------------------------------ |
@@ -1132,7 +922,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Complete Legacy Example: Contract Workflow (Deprecated)
## Complete Example: Contract Workflow
This example demonstrates a complete workflow for using templates to send contracts.
@@ -1206,29 +996,16 @@ async function sendEmploymentContract(employeeData: {
subject: `Employment Contract for ${employeeData.name}`,
message: `Hi ${employeeData.name},\n\nPlease review and sign your employment contract.`,
},
distributeDocument: false,
distributeDocument: true,
externalId: `emp-contract-${Date.now()}`,
}),
});
const document = await documentResponse.json();
// 5. Distribute the envelope and get recipient signing links
const distributionResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: document.envelopeId,
}),
});
const distribution = await distributionResponse.json();
return {
envelopeId: document.envelopeId,
signingUrl: distribution.recipients[0].signingUrl,
documentId: document.id,
signingUrl: document.recipients[0].signingUrl,
};
}
@@ -1241,7 +1018,7 @@ const result = await sendEmploymentContract({
startDate: '2025-03-01',
});
console.log('Document created:', result.envelopeId);
console.log('Document created:', result.documentId);
console.log('Signing URL:', result.signingUrl);
````
@@ -186,6 +186,15 @@ Controls envelope configuration options. Set to `null` to hide envelope settings
| `allowConfigureEmailSender` | `boolean` | `true` | Allow configuring the email sender |
| `allowConfigureEmailReplyTo` | `boolean` | `true` | Allow configuring the email reply-to |
<Callout title="Allow Document Rejection">
The settings dialog also contains an "Allow Document Rejection" option that is always shown. It sets the document's
own rejection setting (`meta.allowDocumentRejection` in the API) and defaults to the organisation or team document
preference. This
is separate from the `allowDocumentRejection` option of the embedded signing views: that option only controls the
signing embed's UI, while this one is stored on the document and enforced by the server. A recipient can reject only
when both allow it.
</Callout>
### Actions
Controls available actions during editing:
@@ -47,10 +47,20 @@ Documenso expects the fragment to be **base64** of:
| `lockEmail` | `boolean` | Lock the email field (prevents editing). |
| `language` | `string` | Force the embed language (e.g. `en`). |
| `darkModeDisabled` | `boolean` | Disable dark mode behavior. |
| `allowDocumentRejection` | `boolean` | Allow or disallow document rejection. |
| `allowDocumentRejection` | `boolean` | Show the reject option in the embed. Defaults to `false`. The document must also allow rejection, see below. |
| `css` | `string` | Inject custom CSS into the embed. |
| `cssVars` | `object` | Override embed CSS variables (see the CSS Variables page). |
<Callout title="allowDocumentRejection and the document's own setting">
Two settings control whether a recipient can reject a document in an embed, and both must allow it:
- `allowDocumentRejection` above, which you pass per embed. It only controls your embed's UI and defaults to `false`.
- The document's own "Allow Document Rejection" setting. Its default comes from the organisation or team document
preferences, and it can be changed per document in the editor or through the API (`meta.allowDocumentRejection`).
When it is disabled, the reject option stays hidden even if you pass `allowDocumentRejection: true`, and the server
refuses rejection requests for that document.
</Callout>
#### Example
```ts
@@ -262,11 +262,8 @@ Use templates for repeatable document workflows. This example creates an employm
Map template fields by label and build a <code>prefillFields</code> array
</Step>
<Step>
Call <code>POST /template/use</code> with recipients and prefill data
</Step>
<Step>
Distribute the returned envelope via <code>POST /envelope/distribute</code> and read its signing
links
Call <code>POST /template/use</code> with recipients, prefill data, and{' '}
<code>distributeDocument: true</code>
</Step>
</Steps>
@@ -300,7 +297,7 @@ type TemplateRecipient = {
async function sendEmploymentContract(
templateId: number,
employee: EmployeeData,
): Promise<{ documentId: number; signingUrl: string }> {
): Promise<{ documentId: string; signingUrl: string }> {
const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, {
headers: { Authorization: API_TOKEN },
});
@@ -377,6 +374,7 @@ async function sendEmploymentContract(
subject: `Your Employment Contract at ${employee.department}`,
message: `Hi ${employee.name},\n\nPlease review and sign your employment contract for the ${employee.position} position.\n\nStart date: ${employee.startDate}`,
},
distributeDocument: true,
externalId: `emp-${Date.now()}-${employee.email.split('@')[0]}`,
}),
});
@@ -388,25 +386,9 @@ async function sendEmploymentContract(
const document = await createResponse.json();
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: document.envelopeId }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
throw new Error(`Failed to send document: ${error.message}`);
}
const distributeResult = await distributeResponse.json();
return {
documentId: document.id,
signingUrl: distributeResult.recipients[0].signingUrl,
signingUrl: document.recipients[0].signingUrl,
};
}
@@ -471,17 +453,12 @@ RESPONSE=$(curl -s -X POST "${BASE_URL}/template/use" \
\"subject\": \"Your Employment Contract\",
\"message\": \"Please review and sign your employment contract.\"
},
\"distributeDocument\": true,
\"externalId\": \"emp-$(date +%s)-alice\"
}")
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
echo "Document created: $(echo $RESPONSE | jq -r '.id')"
echo "Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
echo "Document created:"
echo $RESPONSE | jq '{id, signingUrl: .recipients[0].signingUrl}'
````
</Tab>
@@ -499,10 +476,8 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
Fetch the template and get the signer recipient slot ID
</Step>
<Step>
For each recipient, call <code>POST /template/use</code>
</Step>
<Step>
Distribute each returned envelope via <code>POST /envelope/distribute</code>
For each recipient, call <code>POST /template/use</code> with{' '}
<code>distributeDocument: true</code>
</Step>
<Step>
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
@@ -565,6 +540,7 @@ async function bulkSendFromTemplate(
recipients: [
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
],
distributeDocument: true,
externalId: `bulk-${Date.now()}-${recipient.email}`,
}),
});
@@ -575,26 +551,10 @@ async function bulkSendFromTemplate(
}
const document = await response.json();
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
Authorization: API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: document.envelopeId }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
throw new Error(error.message || 'Failed to distribute document');
}
const distributeResult = await distributeResponse.json();
return {
email: recipient.email,
envelopeId: document.envelopeId,
signingUrl: distributeResult.recipients[0].signingUrl,
envelopeId: document.id,
signingUrl: document.recipients[0].signingUrl,
};
}),
);
@@ -667,24 +627,14 @@ for RECIPIENT in "${RECIPIENTS[@]}"; do
\"email\": \"${EMAIL}\",
\"name\": \"${NAME}\"
}],
\"distributeDocument\": true,
\"externalId\": \"bulk-$(date +%s)-${EMAIL}\"
}")
if echo $RESPONSE | jq -e '.envelopeId' > /dev/null 2>&1; then
ENVELOPE_ID=$(echo $RESPONSE | jq -r '.envelopeId')
DISTRIBUTE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"envelopeId\": \"${ENVELOPE_ID}\"}")
if echo $DISTRIBUTE_RESPONSE | jq -e '.success' > /dev/null 2>&1; then
echo " Success: ${ENVELOPE_ID}"
echo " Signing URL: $(echo $DISTRIBUTE_RESPONSE | jq -r '.recipients[0].signingUrl')"
else
echo " Failed to distribute: $(echo $DISTRIBUTE_RESPONSE | jq -r '.message')"
fi
if echo $RESPONSE | jq -e '.id' > /dev/null 2>&1; then
echo " Success: $(echo $RESPONSE | jq -r '.id')"
else
echo " Failed to create: $(echo $RESPONSE | jq -r '.message')"
echo " Failed: $(echo $RESPONSE | jq -r '.message')"
fi
# Rate limiting delay
@@ -887,15 +837,13 @@ After a document is completed, download the signed PDF with all signatures embed
</Step>
</Steps>
The `version` query parameter accepts `original`, `pending`, or `signed`.
<Tabs items={['TypeScript', 'curl']}>
<Tab value="TypeScript">
```typescript
const API_TOKEN = process.env.DOCUMENSO_API_TOKEN;
const BASE_URL = 'https://app.documenso.com/api/v2';
type DownloadVersion = 'original' | 'pending' | 'signed';
type DownloadVersion = 'signed' | 'original';
async function downloadDocument(
envelopeId: string,
@@ -949,7 +897,7 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
{ headers: { Authorization: API_TOKEN } },
);
const { data, count, currentPage, perPage, totalPages } = await response.json();
const { data, pagination } = await response.json();
for (const envelope of data) {
try {
@@ -963,8 +911,8 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 500));
}
hasMore = currentPage < totalPages;
page = currentPage + 1;
hasMore = page < pagination.totalPages;
page++;
}
}
@@ -24,18 +24,20 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
{/* prettier-ignore */}
<Steps>
<Step>
### Select a team
### Open settings
Log in to Documenso and open the team that the integration should access. API tokens are scoped to a
team.
- Log in to your Documenso account
- Click your avatar in the top right corner
- Select **Settings** from the dropdown menu
![User dropdown menu](/public-api-images/documenso-user-dropdown-menu.webp)
</Step>
<Step>
### Open API Tokens
### Navigate to the API Tokens tab
Go to **Team Settings** → **API Tokens**, or open
`/t/{teamUrl}/settings/tokens` after replacing `{teamUrl}` with your team's URL.
Go to **Settings** and open the **API Tokens** tab.
![API tokens page](/public-api-images/api-tokens-page-documenso.webp)
@@ -46,7 +48,7 @@ Go to **Team Settings** → **API Tokens**, or open
- Click **Create Token**
- Enter a descriptive name (e.g., `production-backend`, `zapier-integration`)
- Select an expiration period: 7 days, 1 month, 3 months, 6 months, 12 months, or Never
- Select an expiration period: never expires, 7 days, 1 month, 3 months, 6 months, or 1 year
- Click **Create Token**
</Step>
@@ -73,21 +75,21 @@ Include the token in the `Authorization` header of your HTTP requests.
### cURL
```bash
curl https://app.documenso.com/api/v2/envelope \
curl https://app.documenso.com/api/v2/document \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
```
### JavaScript / TypeScript
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
const response = await fetch('https://app.documenso.com/api/v2/document', {
method: 'GET',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
},
});
const envelopes = await response.json();
const documents = await response.json();
```
### Using the TypeScript SDK
@@ -127,7 +129,7 @@ SDKs are available for [TypeScript](https://github.com/documenso/sdk-typescript)
## Token Security
API tokens grant full API access to the team they were created for. Follow these practices to keep them secure:
API tokens grant full access to your account. Follow these practices to keep them secure:
- **Never commit tokens to version control.** Use environment variables instead.
- **Use descriptive names.** Names like `zapier-prod` or `backend-staging` help you identify token usage.
@@ -153,11 +155,12 @@ const client = new Documenso({
## Token Scope
API tokens have full API access to the team they were created for, including:
API tokens have full access to your account, including:
- Creating, reading, updating, and deleting documents
- Managing recipients and fields
- Accessing templates
- Managing team resources (if the token owner has team access)
There is currently no way to create tokens with limited scopes or permissions.
@@ -168,7 +171,7 @@ To revoke a token:
{/* prettier-ignore */}
<Steps>
<Step>
Go to **Team Settings** → **API Tokens**
Go to **Settings** > **API Tokens**
</Step>
<Step>
Find the token you want to revoke
@@ -191,7 +194,7 @@ Revoked tokens stop working immediately. Any integrations using that token will
</Accordion>
<Accordion title="401 Unauthorized — Expired token">Create a new token in settings.</Accordion>
<Accordion title="403 Forbidden — Token doesn't have access to the resource">
Ensure you're accessing resources owned by the token's team.
Ensure you're accessing resources owned by the token's account.
</Accordion>
</Accordions>
@@ -78,10 +78,12 @@ A successful response returns a list of your documents (envelopes):
"createdAt": "2025-01-15T10:30:00.000Z"
}
],
"count": 1,
"currentPage": 1,
"perPage": 10,
"totalPages": 1
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 1,
"totalItems": 1
}
}
````
@@ -226,12 +228,9 @@ After creating a document, it's in `DRAFT` status. To send it to recipients, use
<Tabs items={['curl', 'JavaScript']}>
<Tab value="curl">
```bash
curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribute" \
-H "Authorization: YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abc123"
}'
-H "Content-Type: application/json"
````
</Tab>
@@ -239,14 +238,16 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
```javascript
const envelopeId = 'envelope_abc123';
const response = await fetch('https://app.documenso.com/api/v2/envelope/distribute', {
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
const response = await fetch(
`https://app.documenso.com/api/v2/envelope/${envelopeId}/distribute`,
{
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
},
},
body: JSON.stringify({ envelopeId }),
});
);
const data = await response.json();
console.log('Document sent:', data);
@@ -336,14 +337,16 @@ async function createAndSendDocument(pdfPath, recipientEmail, recipientName) {
console.log('Created envelope:', envelope.id);
// Step 2: Send the document for signing
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: envelope.id }),
});
const distributeResponse = await fetch(
`${BASE_URL}/envelope/${envelope.id}/distribute`,
{
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
}
);
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
@@ -419,12 +422,9 @@ echo "Created envelope: ${ENVELOPE_ID}"
# Step 2: Send the document for signing
echo "Sending document..."
curl -s -X POST "${BASE_URL}/envelope/distribute" \
curl -s -X POST "${BASE_URL}/envelope/${ENVELOPE_ID}/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"envelopeId\": \"${ENVELOPE_ID}\"
}"
-H "Content-Type: application/json"
echo "Document sent for signing!"
@@ -441,7 +441,7 @@ The API returns standard HTTP status codes and JSON error responses:
| `400` | Bad request - check your request payload |
| `401` | Unauthorized - invalid or missing API token |
| `404` | Not found - resource doesn't exist |
| `429` | Rate limit or plan quota. If `Retry-After` is present, retry the request. |
| `429` | Rate limited - wait 60 seconds and retry |
| `500` | Server error - retry or contact support |
### Error Response Format
@@ -485,9 +485,7 @@ The API returns standard HTTP status codes and JSON error responses:
### Handling Rate Limits
The API has a limit of 1000 requests per minute for each IP address, and your organisation can have a lower limit. Each response includes `X-RateLimit-Remaining` and `X-RateLimit-Reset` (an epoch timestamp in seconds). A windowed rate-limit `429` response includes `Retry-After`. Wait for that number of seconds before you send the request again. A quota `429` response does not include `Retry-After` because a wait cannot correct the quota error. If `Retry-After` is not present, do not send the request again automatically.
Refer to [Error Handling Patterns](/docs/developers/examples/common-workflows#error-handling-patterns) for more retry information.
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
async function fetchWithRetry(url, options, maxRetries = 3) {
@@ -495,15 +493,8 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
if (!retryAfter) {
return response;
}
const retryAfterSeconds = Number.parseInt(retryAfter, 10);
console.log(`Rate limit. Wait ${retryAfterSeconds} seconds...`);
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
console.log('Rate limited, waiting 60 seconds...');
await new Promise((resolve) => setTimeout(resolve, 60000));
continue;
}
+1 -1
View File
@@ -15,7 +15,7 @@
"fumadocs-ui": "16.14.3",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "^16.3.3",
"next": "16.3.0",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
+128 -9
View File
@@ -1,23 +1,142 @@
/**
* Apply the public statistics API's wildcard CORS policy, without credentials.
* Multi purpose CORS lib.
* Note: Based on the `cors` package in npm but using only web APIs.
* Taken from: https://github.com/vercel/examples/blob/main/edge-functions/cors/lib/cors.ts
*/
export default async function cors(req: Request, res: Response): Promise<Response> {
type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
type OriginFn = (origin: string | undefined, req: Request) => StaticOrigin | Promise<StaticOrigin>;
interface CorsOptions {
origin?: StaticOrigin | OriginFn;
methods?: string | string[];
allowedHeaders?: string | string[];
exposedHeaders?: string | string[];
credentials?: boolean;
maxAge?: number;
preflightContinue?: boolean;
optionsSuccessStatus?: number;
}
const defaultOptions: CorsOptions = {
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
preflightContinue: false,
optionsSuccessStatus: 204,
};
function isOriginAllowed(origin: string, allowed: StaticOrigin): boolean {
return Array.isArray(allowed)
? allowed.some((o) => isOriginAllowed(origin, o))
: typeof allowed === 'string'
? origin === allowed
: allowed instanceof RegExp
? allowed.test(origin)
: !!allowed;
}
function getOriginHeaders(reqOrigin: string | undefined, origin: StaticOrigin) {
const headers = new Headers();
if (origin === '*') {
headers.set('Access-Control-Allow-Origin', '*');
} else if (typeof origin === 'string') {
headers.set('Access-Control-Allow-Origin', origin);
headers.append('Vary', 'Origin');
} else {
const allowed = isOriginAllowed(reqOrigin ?? '', origin);
if (allowed && reqOrigin) {
headers.set('Access-Control-Allow-Origin', reqOrigin);
}
headers.append('Vary', 'Origin');
}
return headers;
}
async function originHeadersFromReq(req: Request, origin: StaticOrigin | OriginFn) {
const reqOrigin = req.headers.get('Origin') || undefined;
const value = typeof origin === 'function' ? await origin(reqOrigin, req) : origin;
if (!value) {
return;
}
return getOriginHeaders(reqOrigin, value);
}
function getAllowedHeaders(req: Request, allowed?: string | string[]) {
const headers = new Headers();
if (!allowed) {
allowed = req.headers.get('Access-Control-Request-Headers')!;
headers.append('Vary', 'Access-Control-Request-Headers');
} else if (Array.isArray(allowed)) {
allowed = allowed.join(',');
}
if (allowed) {
headers.set('Access-Control-Allow-Headers', allowed);
}
return headers;
}
export default async function cors(req: Request, res: Response, options?: CorsOptions) {
const opts = { ...defaultOptions, ...options };
const { headers } = res;
headers.set('Access-Control-Allow-Origin', '*');
const originHeaders = await originHeadersFromReq(req, opts.origin ?? false);
const mergeHeaders = (v: string, k: string) => {
if (k === 'Vary') {
headers.append(k, v);
} else {
headers.set(k, v);
}
};
// If there's no origin we won't touch the response
if (!originHeaders) {
return res;
}
originHeaders.forEach(mergeHeaders);
if (opts.credentials) {
headers.set('Access-Control-Allow-Credentials', 'true');
}
const exposed = Array.isArray(opts.exposedHeaders) ? opts.exposedHeaders.join(',') : opts.exposedHeaders;
if (exposed) {
headers.set('Access-Control-Expose-Headers', exposed);
}
// Handle the preflight request
if (req.method === 'OPTIONS') {
headers.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
headers.set('Vary', 'Access-Control-Request-Headers');
if (opts.methods) {
const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods;
const allowedHeaders = req.headers.get('Access-Control-Request-Headers');
headers.set('Access-Control-Allow-Methods', methods);
}
if (allowedHeaders) {
headers.set('Access-Control-Allow-Headers', allowedHeaders);
getAllowedHeaders(req, opts.allowedHeaders).forEach(mergeHeaders);
if (typeof opts.maxAge === 'number') {
headers.set('Access-Control-Max-Age', String(opts.maxAge));
}
if (opts.preflightContinue) {
return res;
}
headers.set('Content-Length', '0');
return new Response(null, { status: 204, headers });
return new Response(null, { status: opts.optionsSuccessStatus, headers });
}
// If we got here, it's a normal request
return res;
}
export function initCors(options?: CorsOptions) {
return async (req: Request, res: Response) => cors(req, res, options);
}
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "^16.3.3"
"next": "16.3.0"
},
"devDependencies": {
"@types/node": "^20",
@@ -96,6 +96,9 @@ export const DocumentPreferencesResetDialog = ({
<li>
<Trans>Delegate document ownership</Trans>
</li>
<li>
<Trans>Allow document rejection</Trans>
</li>
{showAiFeatures && (
<li>
<Trans>AI features</Trans>
@@ -44,10 +44,7 @@ export const EnvelopesBulkDeleteDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
await trpcUtils.template.findTemplates.invalidate();
}
if (result.failedIds.length > 0) {
@@ -299,7 +299,7 @@ export const EnvelopesBulkDownloadDialog = ({
</Alert>
)}
<fieldset disabled={isDownloading} className="min-w-0 space-y-4">
<fieldset disabled={isDownloading} className="space-y-4">
<div className="-mx-3 max-h-96 overflow-y-auto px-3">
<div className="divide-y divide-border rounded-lg border border-border">
{envelopes.map((envelope) => {
@@ -96,10 +96,7 @@ export const EnvelopesBulkMoveDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
await trpcUtils.template.findTemplates.invalidate();
}
await onSuccess?.(data.folderId);
@@ -8,7 +8,6 @@ import { AppError } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -24,7 +23,6 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -40,64 +38,27 @@ import { useNavigate } from 'react-router';
import * as z from 'zod';
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
const DOCUMENT_NAME_SOURCE = {
TEMPLATE: 'template',
UPLOAD: 'upload',
CUSTOM: 'custom',
} as const;
const getUploadedDocumentTitle = (file: File) => {
return file.name.replace(/\.[^/.]+$/, '').trim();
};
/**
* Whether the file name can be used as a document title.
*/
const isUploadedFileNameUsable = (file?: File): file is File => {
if (!file) {
return false;
}
const title = getUploadedDocumentTitle(file);
return title.length > 0 && title.length <= DOCUMENT_TITLE_MAX_LENGTH;
};
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
documentNameSource: z.enum([
DOCUMENT_NAME_SOURCE.TEMPLATE,
DOCUMENT_NAME_SOURCE.UPLOAD,
DOCUMENT_NAME_SOURCE.CUSTOM,
]),
customDocumentName: z
.string()
.trim()
.max(DOCUMENT_TITLE_MAX_LENGTH, { message: msg`Document name is too long`.id }),
customDocumentData: z
.array(
z.object({
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
}),
)
.optional(),
recipients: z.array(
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.array(
z.object({
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
}),
),
})
.refine((data) => data.documentNameSource !== DOCUMENT_NAME_SOURCE.CUSTOM || data.customDocumentName.length > 0, {
message: msg`Document name is required`.id,
path: ['customDocumentName'],
});
)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
),
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@@ -126,7 +87,6 @@ export function TemplateUseDialog({
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [lastUploadedFile, setLastUploadedFile] = useState<File>();
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
@@ -146,8 +106,6 @@ export function TemplateUseDialog({
return {
distributeDocument: false,
useCustomDocument: false,
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
customDocumentName: '',
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
@@ -182,39 +140,11 @@ export function TemplateUseDialog({
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
/**
* Track the most recently uploaded file so its name can be used as the document name.
* Files with an unusable name are ignored, and the document name source is reset if
* no usable file remains.
*/
const updateLastUploadedFile = (file?: File) => {
const usableFile = isUploadedFileNameUsable(file) ? file : undefined;
setLastUploadedFile(usableFile);
if (!usableFile && form.getValues('documentNameSource') === DOCUMENT_NAME_SOURCE.UPLOAD) {
form.setValue('documentNameSource', DOCUMENT_NAME_SOURCE.TEMPLATE);
}
};
const getDocumentTitle = (data: TAddRecipientsForNewDocumentSchema) => {
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM) {
return data.customDocumentName;
}
if (data.documentNameSource === DOCUMENT_NAME_SOURCE.UPLOAD && lastUploadedFile) {
return getUploadedDocumentTitle(lastUploadedFile);
}
return undefined;
};
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
const documentTitle = getDocumentTitle(data);
const customFilesToUpload = (data.customDocumentData ?? []).filter(
(item): item is typeof item & { data: File } => item.data !== undefined,
const customFilesToUpload = (data.customDocumentData || []).filter(
(item): item is { data: File; envelopeItemId: string; title: string } =>
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
);
const customDocumentData = await Promise.all(
@@ -233,7 +163,6 @@ export function TemplateUseDialog({
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentData,
...(documentTitle ? { override: { title: documentTitle } } : {}),
});
toast({
@@ -266,14 +195,9 @@ export function TemplateUseDialog({
name: 'recipients',
});
const useCustomDocument = form.watch('useCustomDocument');
const documentNameSource = form.watch('documentNameSource');
const canUseUploadedDocumentName = Boolean(lastUploadedFile);
useEffect(() => {
if (open) {
form.reset(generateDefaultFormValues());
setLastUploadedFile(undefined);
}
}, [open, form]);
@@ -314,9 +238,9 @@ export function TemplateUseDialog({
</DialogHeader>
<Form {...form}>
<form className="min-w-0" onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full min-w-0 flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] w-full min-w-0 max-w-full space-y-4 overflow-y-auto overflow-x-hidden p-1">
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
@@ -477,17 +401,7 @@ export function TemplateUseDialog({
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
const customDocumentData = form.getValues('customDocumentData');
form.setValue(
'customDocumentData',
customDocumentData?.map((item) => ({
...item,
data: undefined,
})),
);
form.clearErrors('customDocumentData');
updateLastUploadedFile(undefined);
form.setValue('customDocumentData', undefined);
}
}}
/>
@@ -514,7 +428,7 @@ export function TemplateUseDialog({
)}
/>
{useCustomDocument && (
{form.watch('useCustomDocument') && (
<div className="my-4 space-y-2">
{isLoadingEnvelopeItems ? (
<SpinnerBox className="py-16" />
@@ -529,7 +443,7 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
className="flex w-full min-w-0 items-center gap-4 overflow-hidden rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
>
<div className="flex-shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
@@ -537,15 +451,13 @@ export function TemplateUseDialog({
</div>
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<h4 className="truncate font-medium text-foreground text-sm">
{field.value ? getUploadedDocumentTitle(field.value) : item.title}
</h4>
<div className="min-w-0 flex-1">
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
{field.value ? (
<span>
<div>
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
</span>
</div>
) : (
<Trans>Default file</Trans>
)}
@@ -563,18 +475,6 @@ export function TemplateUseDialog({
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
if (field.value === lastUploadedFile) {
// Fall back to any other uploaded file so the option stays available.
const remainingUploadedFile = form
.getValues('customDocumentData')
?.find(
(item) =>
item.data !== field.value && isUploadedFileNameUsable(item.data),
)?.data;
updateLastUploadedFile(remainingUploadedFile);
}
}}
>
<X className="mr-2 h-4 w-4" />
@@ -617,7 +517,7 @@ export function TemplateUseDialog({
}
if (file.type !== 'application/pdf') {
form.setError(`customDocumentData.${i}.data`, {
form.setError('customDocumentData', {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
@@ -626,7 +526,7 @@ export function TemplateUseDialog({
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError(`customDocumentData.${i}.data`, {
form.setError('customDocumentData', {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
@@ -637,8 +537,6 @@ export function TemplateUseDialog({
}
field.onChange(file);
form.clearErrors(`customDocumentData.${i}.data`);
updateLastUploadedFile(file);
}}
/>
</div>
@@ -652,112 +550,6 @@ export function TemplateUseDialog({
)}
</div>
)}
<FormField
control={form.control}
name="documentNameSource"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Document name</Trans>
</FormLabel>
<FormControl>
<RadioGroup
aria-label={_(msg`Document name`)}
value={field.value}
onValueChange={field.onChange}
className="space-y-2"
>
<div className="flex items-center gap-2">
<RadioGroupItem id="document-name-source-template" value={DOCUMENT_NAME_SOURCE.TEMPLATE} />
<label className="text-sm" htmlFor="document-name-source-template">
<Trans>Use template name</Trans>
</label>
</div>
<div className="flex items-start gap-2">
<RadioGroupItem
id="document-name-source-upload"
value={DOCUMENT_NAME_SOURCE.UPLOAD}
disabled={!canUseUploadedDocumentName}
className="mt-0.5"
/>
<div className="min-w-0">
<div className="flex items-center gap-1">
<label
className={cn('text-sm', {
'cursor-not-allowed text-muted-foreground': !canUseUploadedDocumentName,
})}
htmlFor="document-name-source-upload"
>
<Trans>Use uploaded file name</Trans>
</label>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={_(msg`About uploaded file naming`)}
className="text-muted-foreground"
>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-xs">
<Trans>
The document name will use the most recently uploaded file name without its
extension.
</Trans>
</TooltipContent>
</Tooltip>
</div>
{lastUploadedFile && (
<p
className="max-w-sm truncate text-muted-foreground text-xs"
title={lastUploadedFile.name}
>
{lastUploadedFile.name}
</p>
)}
{!canUseUploadedDocumentName && (
<p className="text-muted-foreground text-xs">
<Trans>Upload a custom document to use its file name.</Trans>
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="document-name-source-custom" value={DOCUMENT_NAME_SOURCE.CUSTOM} />
<label className="text-sm" htmlFor="document-name-source-custom">
<Trans>Enter custom document name</Trans>
</label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{documentNameSource === DOCUMENT_NAME_SOURCE.CUSTOM && (
<FormField
control={form.control}
name="customDocumentName"
render={({ field }) => (
<FormItem className="ml-6">
<FormControl>
<Input
{...field}
aria-label={_(msg`Custom document name`)}
placeholder={_(msg`Enter a document name`)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<DialogFooter className="mt-4">
@@ -96,6 +96,9 @@ export const EmbedSignDocumentV1ClientPage = ({
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
// Both the embed host and the document settings must allow rejection.
const isDocumentRejectionAllowed = allowDocumentRejection && (metadata?.allowDocumentRejection ?? true);
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
@@ -303,7 +306,7 @@ export const EmbedSignDocumentV1ClientPage = ({
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
{allowDocumentRejection && (
{isDocumentRejectionAllowed && (
<DocumentSigningRejectDialog documentId={documentId} token={token} onRejected={onDocumentRejected} />
)}
</div>
@@ -502,7 +505,7 @@ export const EmbedSignDocumentV1ClientPage = ({
</Button>
) : (
<Button
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
className={isDocumentRejectionAllowed ? 'col-start-2' : 'col-span-2'}
disabled={isThrottled}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
@@ -228,7 +228,7 @@ export const MultiSignDocumentSigningView = ({
</Button>
</div>
{allowDocumentRejection && (
{allowDocumentRejection && (document.documentMeta?.allowDocumentRejection ?? true) && (
<div className="embed--Actions mt-8 mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog documentId={document.id} token={token} onRejected={onRejected} />
</div>
@@ -43,6 +43,7 @@ export type TDocumentPreferencesFormSchema = {
signatureTypes: DocumentSignatureType[];
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
allowDocumentRejection: boolean | null;
aiFeaturesEnabled: boolean | null;
};
@@ -57,6 +58,7 @@ type SettingsSubset = Pick<
| 'drawSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'allowDocumentRejection'
| 'aiFeaturesEnabled'
>;
@@ -77,6 +79,7 @@ const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPr
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
allowDocumentRejection: settings.allowDocumentRejection,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
};
};
@@ -101,6 +104,7 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
}),
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
allowDocumentRejection: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
});
@@ -486,6 +490,60 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
)}
/>
<FormField
control={form.control}
name="allowDocumentRejection"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Allow Document Rejection</Trans>}
testId="allow-document-rejection"
>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="allow-document-rejection-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether recipients can reject newly created documents from the signing page. Existing
documents keep their current setting. You can change it per document in the document settings.
</Trans>
</FormDescription>
</InheritableField>
)}
/>
{isAiFeaturesConfigured && (
<FormField
control={form.control}
@@ -138,6 +138,12 @@ export const AdminGlobalSettingsSection = ({
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Allow document rejection</Trans>}>
<DetailsValue>
{booleanValue(settings.allowDocumentRejection, inheritedSettings?.allowDocumentRejection)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
@@ -1,371 +0,0 @@
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import { SearchIcon, UsersIcon } from 'lucide-react';
import type { MouseEvent, ReactNode } from 'react';
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { formatRelativeDate } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
/** A single scope-agnostic row: a team member on the team page, a team on the organisation page. */
export type AnalyticsActivityRow = {
key: string | number;
avatar: {
imageId: string | null;
fallback: string;
};
title: string;
subtitle?: string | null;
sent: number;
completed: number;
pending: number;
/** 0-100, null when nothing was sent. */
completionRate: number | null;
lastActiveAt: Date | null;
/** When set the whole row navigates here and the title becomes a link. */
href?: string;
};
export type AnalyticsActivityTableCardProps = {
query: AnalyticsQueryResult<unknown>;
/** Rows derived from `query.data`; empty while loading. */
rows: AnalyticsActivityRow[];
/** Identifies the current window (preset or custom span), so "Show all" resets whenever it changes. */
rangeKey: string;
title: ReactNode;
description: ReactNode;
/** Header of the first column, e.g. "Member". */
columnLabel: ReactNode;
/** Rendered next to the title once rows are loaded, e.g. "3 members · 2 active this period". */
renderSummary: (count: number, activeCount: number) => ReactNode;
/** Rendered next to "Show all", e.g. "Showing 8 of 9 members". */
renderShowing: (visibleCount: number, totalCount: number) => ReactNode;
emptyLabel: ReactNode;
emptyIcon?: LucideIcon;
/** Placeholder for the search input, e.g. "Search members". */
searchPlaceholder: string;
/** Rendered when the search matches nothing, e.g. "No members match your search". */
noSearchResultsLabel: ReactNode;
/** Builds the `analytics-{prefix}-*` test ids, e.g. `member` or `team`. */
testIdPrefix: string;
className?: string;
};
export const AnalyticsActivityTableCard = ({
query,
rows,
rangeKey,
title,
description,
columnLabel,
renderSummary,
renderShowing,
emptyLabel,
emptyIcon: EmptyIcon = UsersIcon,
searchPlaceholder,
noSearchResultsLabel,
testIdPrefix,
className,
}: AnalyticsActivityTableCardProps) => {
const { i18n } = useLingui();
// Tracks which window "Show all" was pressed for, so it resets whenever the window changes.
const [expandedRangeKey, setExpandedRangeKey] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const isExpanded = expandedRangeKey === rangeKey;
const { data, isLoading, isError, refetch } = query;
const activeCount = rows.filter((row) => row.sent > 0).length;
const normalisedSearchTerm = searchTerm.trim().toLowerCase();
const isSearching = normalisedSearchTerm.length > 0;
// Search always shows every match; the preview limit only applies to the unfiltered list.
const filteredRows = isSearching ? rows.filter((row) => matchesSearch(row, normalisedSearchTerm)) : rows;
const visibleRows = isExpanded || isSearching ? filteredRows : filteredRows.slice(0, ROW_PREVIEW_LIMIT);
const hasHiddenRows = filteredRows.length > visibleRows.length;
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
return (
<Card className={className} data-testid={testId('activity')}>
<CardHeader className="gap-4 space-y-0 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col space-y-1.5">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
{data !== undefined && rows.length > 0 && (
<p className="shrink-0 text-muted-foreground text-sm tabular-nums" data-testid={testId('summary')}>
{renderSummary(rows.length, activeCount)}
</p>
)}
</CardHeader>
<CardContent>
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || data === undefined ? (
<ul className="flex flex-col gap-y-3">
{Array.from({ length: 4 }, (_, index) => (
<li key={index} className="flex items-center gap-x-3">
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-4 w-10" />
<Skeleton className="hidden h-4 w-10 md:block" />
<Skeleton className="hidden h-4 w-10 md:block" />
<Skeleton className="h-4 w-28" />
<Skeleton className="hidden h-4 w-20 md:block" />
</li>
))}
</ul>
) : rows.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<EmptyIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="max-w-sm text-muted-foreground text-sm">{emptyLabel}</p>
</div>
) : (
<div className="flex flex-col gap-y-3">
<div className="relative sm:max-w-xs">
<SearchIcon
className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
className="pl-9"
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
data-testid={testId('search')}
/>
</div>
{filteredRows.length === 0 ? (
<p className="py-8 text-center text-muted-foreground text-sm" data-testid={testId('no-results')}>
{noSearchResultsLabel}
</p>
) : (
<>
{/* Pull the table out to the card edge so the first/last cell gutters (px-6) line up with the header. */}
<div className="-mx-6">
<Table className="[&_td]:px-2 md:[&_td]:px-4 [&_th]:px-2 md:[&_th]:px-4">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className={FIRST_CELL_CLASS}>{columnLabel}</TableHead>
<TableHead className="text-right">
<Trans>Sent</Trans>
</TableHead>
<TableHead className="hidden text-right md:table-cell">
<Trans>Completed</Trans>
</TableHead>
<TableHead className="hidden text-right md:table-cell">
<Trans>Pending</Trans>
</TableHead>
<TableHead className={cn('text-right', LAST_CELL_ON_MOBILE_CLASS)}>
<Trans>Completion rate</Trans>
</TableHead>
<TableHead className={cn('hidden text-right md:table-cell', LAST_CELL_CLASS)}>
<Trans>Last active</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<ActivityRow key={row.key} row={row} locale={i18n.locale} testIdPrefix={testIdPrefix} />
))}
</TableBody>
</Table>
</div>
{hasHiddenRows && (
<div className="flex items-center justify-between gap-x-4 border-border border-t pt-3">
<p className="text-muted-foreground text-sm" data-testid={testId('showing')}>
{renderShowing(visibleRows.length, filteredRows.length)}
</p>
<Button
variant="ghost"
size="sm"
className="-mr-2"
onClick={() => setExpandedRangeKey(rangeKey)}
data-testid={testId('show-all')}
>
<Trans>Show all</Trans>
</Button>
</div>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
);
};
type ActivityRowProps = {
row: AnalyticsActivityRow;
locale: string;
testIdPrefix: string;
};
const ActivityRow = ({ row, locale, testIdPrefix }: ActivityRowProps) => {
const { _ } = useLingui();
const navigate = useNavigate();
const isActive = row.sent > 0;
const testId = (suffix: string) => `analytics-${testIdPrefix}-${suffix}`;
// Only "Completed" and the rate are emphasised; supporting counts stay muted. Inactive rows are muted throughout.
const primaryNumberClass = cn('text-right tabular-nums', isActive ? 'text-foreground' : 'text-muted-foreground');
const secondaryNumberClass = 'text-right text-muted-foreground tabular-nums';
// role="img" so the aria-label is valid (a bare span has no role that supports it).
const notAvailable = (
<span role="img" aria-label={_(msg`Not available`)}>
—
</span>
);
/**
* The title link is the accessible target; clicking anywhere else on the row
* navigates too. Modifier clicks and clicks on the link itself are left to the
* browser so open-in-new-tab keeps working, and drag-selecting text does not
* navigate.
*/
const handleRowClick = (event: MouseEvent<HTMLTableRowElement>) => {
if (!row.href || event.defaultPrevented || event.button !== 0) {
return;
}
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
if (event.target instanceof Element && event.target.closest('a')) {
return;
}
if (window.getSelection()?.toString()) {
return;
}
void navigate(row.href);
};
return (
<TableRow
className={cn(row.href && 'cursor-pointer')}
onClick={handleRowClick}
data-testid={testId('row')}
data-active={isActive ? 'true' : 'false'}
>
{/* w-full + max-w-0 lets this cell absorb the remaining width while still truncating its content. */}
<TableCell truncate={false} className={cn('w-full max-w-0', FIRST_CELL_CLASS)}>
<div className="flex min-w-0 items-center gap-x-3">
<Avatar className="h-9 w-9 shrink-0">
{row.avatar.imageId && <AvatarImage src={formatAvatarUrl(row.avatar.imageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{row.avatar.fallback}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
{row.href ? (
<Link
to={row.href}
className={cn(
'truncate font-medium text-sm hover:underline',
isActive ? 'text-foreground' : 'text-foreground/80',
)}
>
{row.title}
</Link>
) : (
<span className={cn('truncate font-medium text-sm', isActive ? 'text-foreground' : 'text-foreground/80')}>
{row.title}
</span>
)}
{row.subtitle && <span className="truncate text-muted-foreground text-xs">{row.subtitle}</span>}
</div>
</div>
</TableCell>
<TableCell className={secondaryNumberClass} data-testid={testId('sent')}>
{row.sent.toLocaleString(locale)}
</TableCell>
<TableCell className={cn('hidden md:table-cell', primaryNumberClass)} data-testid={testId('completed')}>
{row.completed.toLocaleString(locale)}
</TableCell>
<TableCell className={cn('hidden md:table-cell', secondaryNumberClass)} data-testid={testId('pending')}>
{row.pending.toLocaleString(locale)}
</TableCell>
<TableCell className={cn(primaryNumberClass, LAST_CELL_ON_MOBILE_CLASS)} data-testid={testId('completion-rate')}>
{row.completionRate === null ? (
notAvailable
) : (
<div className="flex items-center justify-end gap-x-2">
<div className="hidden h-1.5 w-16 overflow-hidden rounded-full bg-muted sm:block" aria-hidden="true">
<div className="h-full rounded-full bg-primary" style={{ width: `${row.completionRate}%` }} />
</div>
<span className="w-9 text-right">{Math.round(row.completionRate)}%</span>
</div>
)}
</TableCell>
<TableCell
className={cn('hidden md:table-cell', secondaryNumberClass, LAST_CELL_CLASS)}
data-testid={testId('last-active')}
>
{row.lastActiveAt === null ? notAvailable : formatRelativeDate(row.lastActiveAt, locale)}
</TableCell>
</TableRow>
);
};
const ROW_PREVIEW_LIMIT = 8;
const matchesSearch = (row: AnalyticsActivityRow, term: string) => {
return row.title.toLowerCase().includes(term) || (row.subtitle ?? '').toLowerCase().includes(term);
};
/**
* The table is pulled out to the card edge (-mx-6), so the outer cells get the
* card's px-6 gutter to line up with the header. "Last active" is hidden below
* md, so "Completion rate" takes the right gutter there.
*/
const FIRST_CELL_CLASS = '!pl-6';
const LAST_CELL_CLASS = '!pr-6';
const LAST_CELL_ON_MOBILE_CLASS = '!pr-6 md:!pr-4';
@@ -1,203 +0,0 @@
import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { BarChart3Icon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { AnalyticsQueryResult, AnalyticsRangeValue } from '~/utils/analytics';
import { getAnalyticsDateRangeDays } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsDocumentsOverTimeCardProps = {
range: AnalyticsRangeValue;
query: AnalyticsQueryResult<TGetTeamAnalyticsDocumentsOverTimeResponse>;
className?: string;
};
type Bucket = TGetTeamAnalyticsDocumentsOverTimeResponse['range']['bucket'];
export const AnalyticsDocumentsOverTimeCard = ({ range, query, className }: AnalyticsDocumentsOverTimeCardProps) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
// The backend decides the bucket, and it must match the points being rendered so
// the tick and tooltip formatting line up. Before data arrives it is guessed from
// the requested range.
const bucket: Bucket = data ? data.range.bucket : guessBucket(range);
const tickInterval = data ? getTickInterval(data.points.length, bucket) : 0;
return (
<Card className={cn('flex flex-col', className)} data-testid="analytics-documents-over-time">
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
<div className="space-y-1.5">
<CardTitle>
<Trans>Documents created</Trans>
</CardTitle>
<CardDescription>{bucket === 'month' ? <Trans>Monthly</Trans> : <Trans>Daily</Trans>}</CardDescription>
</div>
{data && (
<p className="shrink-0 whitespace-nowrap" data-testid="analytics-documents-over-time-total">
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
{data.total.toLocaleString(i18n.locale)}
</span>{' '}
<span className="text-muted-foreground text-sm">
<Trans>total</Trans>
</span>
</p>
)}
</CardHeader>
{/* flex-1 + justify-center keeps the fixed-height chart vertically level with the status breakdown card. */}
<CardContent className="flex flex-1 flex-col justify-center">
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<Skeleton className="w-full" style={{ height: CHART_HEIGHT }} />
) : data.total === 0 ? (
<div
className="flex flex-col items-center justify-center gap-y-3 text-center"
style={{ height: CHART_HEIGHT }}
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<BarChart3Icon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="text-muted-foreground text-sm">
<Trans>No documents created in this period</Trans>
</p>
</div>
) : (
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
<BarChart data={data.points} margin={{ top: 8, right: 0, bottom: 0, left: 0 }} barCategoryGap="20%">
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="date"
interval={tickInterval}
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
tickFormatter={(value: string) => formatTickLabel(value, bucket, i18n.locale)}
/>
<YAxis
allowDecimals={false}
tickLine={false}
axisLine={false}
width={36}
tickCount={4}
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
/>
<Tooltip
content={<DocumentsOverTimeTooltip bucket={bucket} locale={i18n.locale} />}
cursor={{ fill: 'hsl(var(--muted-foreground) / 0.08)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={28}
background={{ fill: 'hsl(var(--muted) / 0.5)', radius: 4 }}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
};
type DocumentsOverTimeTooltipProps = {
active?: boolean;
payload?: Array<{ payload: { date: string; count: number } }>;
bucket: Bucket;
locale: string;
};
const DocumentsOverTimeTooltip = ({ active, payload, bucket, locale }: DocumentsOverTimeTooltipProps) => {
const point = payload?.[0]?.payload;
if (!active || !point) {
return null;
}
const count = Number(point.count ?? 0);
return (
<div className="rounded-md border border-border bg-popover px-3 py-2 text-popover-foreground text-sm shadow-md">
<p className="text-muted-foreground text-xs">{formatTooltipLabel(point.date, bucket, locale)}</p>
<p className="mt-0.5 font-medium tabular-nums">
<Plural value={count} one="# document" other="# documents" />
</p>
</div>
);
};
const CHART_HEIGHT = 240;
const TARGET_DAILY_TICK_COUNT = 6;
/** Mirrors the backend resolver: custom windows longer than this are bucketed by month. */
const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92;
const guessBucket = (range: AnalyticsRangeValue): Bucket => {
if (range.range === '12m') {
return 'month';
}
if (range.range === 'custom') {
return getAnalyticsDateRangeDays(range.from, range.to) > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day';
}
return 'day';
};
/**
* Month buckets label every month (12 fit at the lg width) and let recharts drop
* overlapping ones on narrow screens; daily buckets show roughly six evenly spaced labels.
*/
const getTickInterval = (pointCount: number, bucket: Bucket): number | 'preserveStartEnd' => {
if (bucket === 'month') {
return 'preserveStartEnd';
}
if (pointCount <= TARGET_DAILY_TICK_COUNT) {
return 0;
}
return Math.max(0, Math.round(pointCount / TARGET_DAILY_TICK_COUNT) - 1);
};
const formatTickLabel = (date: string, bucket: Bucket, locale: string) => {
const parsed = DateTime.fromISO(date).setLocale(locale);
if (bucket === 'month') {
return parsed.toLocaleString({ month: 'short' });
}
return parsed.toLocaleString({ month: 'short', day: 'numeric' });
};
const formatTooltipLabel = (date: string, bucket: Bucket, locale: string) => {
const parsed = DateTime.fromISO(date).setLocale(locale);
if (bucket === 'month') {
return parsed.toLocaleString({ month: 'long', year: 'numeric' });
}
return parsed.toLocaleString(DateTime.DATE_FULL);
};
@@ -1,17 +0,0 @@
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Trans } from '@lingui/react/macro';
/**
* Shown while the analytics route's `clientLoader` resolves the browser timezone
* during hydration.
*/
export const AnalyticsHydrateFallback = () => {
return (
<div role="status" aria-live="polite" data-testid="analytics-loading">
<SpinnerBox />
<span className="sr-only">
<Trans>Loading analytics</Trans>
</span>
</div>
);
};
@@ -1,45 +0,0 @@
import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
import { ANALYTICS_NO_ACTIVITY_LABELS } from '~/utils/analytics';
export type AnalyticsNoActivityAlertProps = {
range: TTeamAnalyticsRange;
/** Invoked when the user asks to widen the range to the last 12 months. */
onShowLastYear: () => void;
};
export const AnalyticsNoActivityAlert = ({ range, onShowLastYear }: AnalyticsNoActivityAlertProps) => {
const { _ } = useLingui();
const canWidenRange = range !== '12m';
return (
<Alert variant="neutral" padding="tight" className="mt-6" data-testid="analytics-no-activity">
<AlertDescription className="flex min-h-9 flex-wrap items-center justify-between gap-x-4 gap-y-2">
<span className="flex items-center gap-x-2">
<InfoIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>
{_(ANALYTICS_NO_ACTIVITY_LABELS[range])}
{canWidenRange && (
<>
{' '}
<Trans>Try a longer range.</Trans>
</>
)}
</span>
</span>
{canWidenRange && (
<Button variant="ghost" size="sm" className="-mr-2" onClick={onShowLastYear}>
<Trans>Show last 12 months</Trans>
</Button>
)}
</AlertDescription>
</Alert>
);
};
@@ -1,214 +0,0 @@
import type { TGetTeamAnalyticsOverviewResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { LucideIcon } from 'lucide-react';
import { ArrowDownRightIcon, ArrowUpRightIcon, CircleCheckIcon, SendIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { AnalyticsStatCard } from './analytics-stat-card';
/** The part of the overview response shared by the team and organisation procedures. */
export type AnalyticsOverviewData = Pick<TGetTeamAnalyticsOverviewResponse, 'sent' | 'completionRate'>;
/**
* The third card counts the scope's "entities" (team members, organisation teams)
* and how many of them were active in the period.
*/
export type AnalyticsOverviewEntityCard<TData> = {
icon: LucideIcon;
title: ReactNode;
/** Applied to the value element, e.g. `analytics-members`. */
testId: string;
select: (data: TData) => { active: number; total: number };
};
export type AnalyticsOverviewCardsProps<TData extends AnalyticsOverviewData> = {
query: AnalyticsQueryResult<TData>;
entity: AnalyticsOverviewEntityCard<TData>;
};
export const AnalyticsOverviewCards = <TData extends AnalyticsOverviewData>({
query,
entity,
}: AnalyticsOverviewCardsProps<TData>) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
const entityCounts = data ? entity.select(data) : null;
const formatNumber = (value: number) => value.toLocaleString(i18n.locale);
const sharedProps = {
isLoading: isLoading || !data,
isError,
onRetry: refetch,
};
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<AnalyticsStatCard
{...sharedProps}
icon={SendIcon}
title={<Trans>Documents sent</Trans>}
value={data ? formatNumber(data.sent.current) : null}
badge={data ? <SentDeltaBadge current={data.sent.current} previous={data.sent.previous} /> : null}
description={<Trans>vs. previous period</Trans>}
testId="analytics-sent"
/>
<AnalyticsStatCard
{...sharedProps}
icon={CircleCheckIcon}
title={<Trans>Completion rate</Trans>}
value={data ? formatRate(data.completionRate.rate) : null}
badge={
data ? (
<CompletionRateDeltaBadge rate={data.completionRate.rate} previousRate={data.completionRate.previousRate} />
) : null
}
description={<Trans>of sent documents completed</Trans>}
testId="analytics-completion-rate"
/>
<AnalyticsStatCard
{...sharedProps}
icon={entity.icon}
title={entity.title}
value={entityCounts ? `${formatNumber(entityCounts.active)}/${formatNumber(entityCounts.total)}` : null}
description={
entityCounts ? (
<Trans>
{formatNumber(entityCounts.active)} active · {formatNumber(entityCounts.total - entityCounts.active)}{' '}
inactive
</Trans>
) : null
}
testId={entity.testId}
/>
</div>
);
};
type SentDeltaBadgeProps = {
current: number;
previous: number;
};
const SentDeltaBadge = ({ current, previous }: SentDeltaBadgeProps) => {
if (previous === 0 && current === 0) {
return null;
}
if (previous === 0) {
return (
<DeltaBadge tone="new" testId="analytics-sent-delta">
<Trans>New</Trans>
</DeltaBadge>
);
}
const delta = Math.round(((current - previous) / previous) * 100);
// Percentages off a tiny base (e.g. 1 → 165) are noise; cap the display.
const label =
delta > MAX_DISPLAYED_DELTA_PERCENT ? `>${MAX_DISPLAYED_DELTA_PERCENT}%` : `${formatSignedNumber(delta)}%`;
return (
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-sent-delta">
{label}
</DeltaBadge>
);
};
type CompletionRateDeltaBadgeProps = {
rate: number | null;
previousRate: number | null;
};
const CompletionRateDeltaBadge = ({ rate, previousRate }: CompletionRateDeltaBadgeProps) => {
if (rate === null || previousRate === null) {
return null;
}
// Compare the rounded values so the delta always agrees with the displayed rate.
const delta = Math.round(rate) - Math.round(previousRate);
return (
<DeltaBadge tone={getDeltaTone(delta)} testId="analytics-completion-rate-delta">
{formatSignedNumber(delta)}%
</DeltaBadge>
);
};
type DeltaTone = 'positive' | 'negative' | 'zero' | 'new';
type DeltaBadgeProps = {
tone: DeltaTone;
testId: string;
children: ReactNode;
};
const DeltaBadge = ({ tone, testId, children }: DeltaBadgeProps) => {
const DeltaIcon = DELTA_TONE_ICONS[tone];
return (
<span
className={cn(
'inline-flex items-center gap-x-0.5 rounded-full px-1.5 py-0.5 font-medium text-xs tabular-nums leading-none',
DELTA_TONE_CLASSES[tone],
)}
data-testid={testId}
>
{DeltaIcon && <DeltaIcon className="-ml-0.5 h-3 w-3" aria-hidden="true" />}
{children}
</span>
);
};
const DELTA_TONE_CLASSES: Record<DeltaTone, string> = {
positive: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
negative: 'bg-red-500/10 text-red-600 dark:text-red-400',
zero: 'bg-muted text-muted-foreground',
new: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
};
const MAX_DISPLAYED_DELTA_PERCENT = 999;
const DELTA_TONE_ICONS: Record<DeltaTone, typeof ArrowUpRightIcon | null> = {
positive: ArrowUpRightIcon,
negative: ArrowDownRightIcon,
zero: null,
new: null,
};
const formatRate = (rate: number | null) => {
if (rate === null) {
return '—';
}
return `${Math.round(rate)}%`;
};
const formatSignedNumber = (value: number) => {
if (value > 0) {
return `+${value}`;
}
return String(value);
};
const getDeltaTone = (delta: number): DeltaTone => {
if (delta > 0) {
return 'positive';
}
if (delta < 0) {
return 'negative';
}
return 'zero';
};
@@ -1,67 +0,0 @@
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ReactNode } from 'react';
import type { AnalyticsRangeValue } from '~/utils/analytics';
import { ANALYTICS_RANGE_LABELS, formatAnalyticsDateRange } from '~/utils/analytics';
import { AnalyticsRangePicker } from './analytics-range-picker';
export type AnalyticsPageHeaderProps = {
avatarImageId: string | null;
/** The team or organisation name. */
name: string;
range: AnalyticsRangeValue;
onRangeChange: (range: AnalyticsRangeValue) => void;
/** Rendered before the range picker, e.g. a link to a related analytics page. */
actions?: ReactNode;
className?: string;
};
export const AnalyticsPageHeader = ({
avatarImageId,
name,
range,
onRangeChange,
actions,
className,
}: AnalyticsPageHeaderProps) => {
const { _, i18n } = useLingui();
const rangeLabel =
range.range === 'custom'
? formatAnalyticsDateRange(range.from, range.to, i18n.locale)
: _(ANALYTICS_RANGE_LABELS[range.range]);
return (
<div className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{avatarImageId && <AvatarImage src={formatAvatarUrl(avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div>
<h2 className="font-semibold text-4xl">
<Trans>Analytics</Trans>
</h2>
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
Usage overview for {name} · {rangeLabel}
</Trans>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{actions}
<AnalyticsRangePicker value={range} onValueChange={onRangeChange} />
</div>
</div>
);
};
@@ -1,37 +0,0 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type AnalyticsQueryErrorProps = {
onRetry: () => Promise<unknown>;
className?: string;
};
export const AnalyticsQueryError = ({ onRetry, className }: AnalyticsQueryErrorProps) => {
const [isRetrying, setIsRetrying] = useState(false);
const handleRetry = async () => {
setIsRetrying(true);
try {
await onRetry();
} finally {
setIsRetrying(false);
}
};
return (
<Alert variant="neutral" padding="tight" className={className} data-testid="analytics-error">
<AlertDescription className="flex flex-wrap items-center justify-between gap-2">
<span>
<Trans>This data could not be loaded.</Trans>
</span>
<Button variant="outline" size="sm" onClick={() => void handleRetry()} loading={isRetrying}>
<Trans>Retry</Trans>
</Button>
</AlertDescription>
</Alert>
);
};
@@ -1,264 +0,0 @@
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Button } from '@documenso/ui/primitives/button';
import type { CalendarProps } from '@documenso/ui/primitives/calendar';
import { Calendar } from '@documenso/ui/primitives/calendar';
import { Popover, PopoverAnchor, PopoverContent } from '@documenso/ui/primitives/popover';
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { useRef, useState } from 'react';
import type { AnalyticsRangeValue, TAnalyticsPresetRange } from '~/utils/analytics';
import {
ANALYTICS_PRESET_RANGES,
ANALYTICS_RANGE_LABELS,
formatAnalyticsDate,
formatAnalyticsDateRange,
getAnalyticsDateRangeDays,
} from '~/utils/analytics';
export type AnalyticsRangePickerProps = {
value: AnalyticsRangeValue;
onValueChange: (value: AnalyticsRangeValue) => void;
};
/** The calendar selection while the popover is open; `to` is unset until the second day is picked. */
type DraftRange = {
from: Date | undefined;
to: Date | undefined;
};
/** A single react-day-picker matcher, e.g. `{ after: Date }`. */
type DayMatcher = Exclude<CalendarProps['disabled'], undefined | unknown[]>;
/**
* A preset select with a "Custom range…" item that opens a two month range
* calendar anchored to the select. The custom window is only committed when
* "Apply" is pressed.
*/
export const AnalyticsRangePicker = ({ value, onValueChange }: AnalyticsRangePickerProps) => {
const { _, i18n } = useLingui();
const { width } = useWindowSize();
const triggerRef = useRef<HTMLButtonElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [draft, setDraft] = useState<DraftRange | undefined>();
const numberOfMonths = width >= SM_BREAKPOINT ? 2 : 1;
const today = DateTime.local().startOf('day');
const openPicker = () => {
setDraft(
value.range === 'custom'
? { from: DateTime.fromISO(value.from).toJSDate(), to: DateTime.fromISO(value.to).toJSDate() }
: undefined,
);
setIsPickerOpen(true);
};
const closePicker = () => {
setIsPickerOpen(false);
setDraft(undefined);
};
const handleSelectValueChange = (nextValue: string) => {
if (nextValue === CUSTOM_RANGE_VALUE) {
openPicker();
return;
}
const preset = ANALYTICS_PRESET_RANGES.find((range) => range === nextValue);
if (!preset) {
return;
}
onValueChange({ range: preset });
};
/**
* Picking a day starts a new window unless one end is already pending, in which
* case it completes it. This replaces react-day-picker's default, which extends
* a completed window instead of starting over.
*/
const handleDaySelect = (_nextRange: unknown, day: Date) => {
if (draft?.from && !draft.to) {
setDraft(day < draft.from ? { from: day, to: draft.from } : { from: draft.from, to: day });
return;
}
setDraft({ from: day, to: undefined });
};
const handleApply = () => {
if (!draft?.from || !draft.to) {
return;
}
onValueChange({ range: 'custom', from: formatAnalyticsDate(draft.from), to: formatAnalyticsDate(draft.to) });
closePicker();
};
// Only the last year (plus a day) up to today is selectable.
const earliestDay = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK);
const disabledDays: DayMatcher[] = [{ before: earliestDay.toJSDate() }, { after: today.toJSDate() }];
// Open on the month of the pending window (or today), keeping the current month
// as the right-most one so no fully disabled future month is shown.
const anchorMonth = draft?.from ? DateTime.fromJSDate(draft.from).startOf('month') : today.startOf('month');
const lastVisibleMonth = today.startOf('month').minus({ months: numberOfMonths - 1 });
const defaultMonth = DateTime.min(anchorMonth, lastVisibleMonth).toJSDate();
const draftFrom = draft?.from ? formatAnalyticsDate(draft.from) : null;
const draftTo = draft?.to ? formatAnalyticsDate(draft.to) : null;
const draftDays = draftFrom && draftTo ? getAnalyticsDateRangeDays(draftFrom, draftTo) : 0;
const customLabel =
value.range === 'custom' ? formatAnalyticsDateRange(value.from, value.to, i18n.locale) : undefined;
return (
<Popover
open={isPickerOpen}
onOpenChange={(open) => {
if (!open) {
closePicker();
}
}}
>
{/*
* The select never holds "custom" as its value so choosing "Custom range…" always
* fires a change, letting an active custom window be adjusted. The trigger shows
* the formatted window through the placeholder instead.
*/}
<Select value={value.range === 'custom' ? '' : value.range} onValueChange={handleSelectValueChange}>
<PopoverAnchor asChild>
<SelectTrigger
ref={triggerRef}
className="w-full sm:w-auto sm:min-w-44"
aria-label={_(msg`Date range`)}
data-testid="analytics-range"
>
<SelectValue placeholder={customLabel} />
</SelectTrigger>
</PopoverAnchor>
<SelectContent position="popper">
{ANALYTICS_PRESET_OPTIONS.map(({ value: optionValue, label }) => (
<SelectItem key={optionValue} value={optionValue}>
{_(label)}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={CUSTOM_RANGE_VALUE} data-testid="analytics-range-custom">
<Trans>Custom range…</Trans>
</SelectItem>
</SelectContent>
</Select>
<PopoverContent
ref={contentRef}
align="end"
className="w-auto p-0"
// The select refocuses its trigger (asynchronously) as it closes, which
// would otherwise dismiss the popover that has just opened and strand
// keyboard focus outside it. Pointer interaction with the trigger still
// dismisses the popover so the select can be reopened.
onFocusOutside={(event) => {
if (!(event.target instanceof Node) || !triggerRef.current?.contains(event.target)) {
return;
}
event.preventDefault();
const content = contentRef.current;
const firstTabbable = content?.querySelector<HTMLElement>(TABBABLE_SELECTOR);
(firstTabbable ?? content)?.focus();
}}
// There is no popover trigger element, so hand focus back to the select.
onCloseAutoFocus={(event) => {
event.preventDefault();
triggerRef.current?.focus();
}}
>
<div data-testid="analytics-range-calendar">
<Calendar
mode="range"
selected={draft}
onSelect={handleDaySelect}
numberOfMonths={numberOfMonths}
// Adjacent months would otherwise show the same days twice.
showOutsideDays={false}
defaultMonth={defaultMonth}
fromDate={earliestDay.toJSDate()}
toDate={today.toJSDate()}
disabled={disabledDays}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-border border-t px-3 py-2">
<p className="text-muted-foreground text-sm" aria-live="polite">
{draftFrom && draftTo ? (
<>
{formatAnalyticsDateRange(draftFrom, draftTo, i18n.locale)} ·{' '}
<Plural value={draftDays} one="# day" other="# days" />
</>
) : draftFrom ? (
<Trans>Pick an end date</Trans>
) : (
<Trans>Pick a start date</Trans>
)}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="secondary" size="sm" onClick={closePicker}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
size="sm"
onClick={handleApply}
disabled={!draftFrom || !draftTo}
data-testid="analytics-range-apply"
>
<Trans>Apply</Trans>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
const CUSTOM_RANGE_VALUE = 'custom';
/** Tailwind `sm` breakpoint; two months are shown from here up. */
const SM_BREAKPOINT = 640;
/** First element the popover should focus: the calendar's month navigation, then the days. */
const TABBABLE_SELECTOR = 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]';
const ANALYTICS_PRESET_OPTIONS = ANALYTICS_PRESET_RANGES.map((value: TAnalyticsPresetRange) => ({
value,
label: ANALYTICS_RANGE_LABELS[value],
}));
@@ -1,63 +0,0 @@
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsStatCardProps = {
icon: LucideIcon;
title: ReactNode;
value: ReactNode;
description: ReactNode;
badge?: ReactNode;
isLoading: boolean;
isError: boolean;
onRetry: () => Promise<unknown>;
testId: string;
};
export const AnalyticsStatCard = ({
icon: Icon,
title,
value,
description,
badge,
isLoading,
isError,
onRetry,
testId,
}: AnalyticsStatCardProps) => {
return (
<Card>
<CardContent className="flex flex-col p-5">
<div className="flex items-center justify-between gap-x-3">
<h3 className="font-medium text-muted-foreground text-sm">{title}</h3>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</div>
{isError ? (
<AnalyticsQueryError onRetry={onRetry} className="mt-3" />
) : isLoading ? (
<div className="mt-3 flex flex-col gap-y-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-3.5 w-32" />
</div>
) : (
<>
<div className="mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1">
<p className="font-semibold text-3xl text-foreground tabular-nums tracking-tight" data-testid={testId}>
{value}
</p>
{badge}
</div>
<p className="mt-1 text-muted-foreground text-xs">{description}</p>
</>
)}
</CardContent>
</Card>
);
};
@@ -1,198 +0,0 @@
import type { TGetTeamAnalyticsStatusBreakdownResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
export type AnalyticsStatusBreakdownCardProps = {
query: AnalyticsQueryResult<TGetTeamAnalyticsStatusBreakdownResponse>;
className?: string;
};
export const AnalyticsStatusBreakdownCard = ({ query, className }: AnalyticsStatusBreakdownCardProps) => {
const { _, i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
const rows = data ? allocatePercentages(STATUS_ROWS.map((row) => ({ ...row, count: data[row.key] }))) : [];
return (
<Card className={cn('flex flex-col', className)} data-testid="analytics-status-breakdown">
<CardHeader className="flex flex-row items-start justify-between gap-x-4 space-y-0">
<div className="space-y-1.5">
<CardTitle>
<Trans>Status breakdown</Trans>
</CardTitle>
<CardDescription>
<Trans>Documents created in this period</Trans>
</CardDescription>
</div>
{data && (
<p className="shrink-0 whitespace-nowrap">
<span className="font-semibold text-2xl text-foreground tabular-nums tracking-tight">
{data.total.toLocaleString(i18n.locale)}
</span>{' '}
<span className="text-muted-foreground text-sm">
<Trans>total</Trans>
</span>
</p>
)}
</CardHeader>
<CardContent className="flex flex-1 flex-col">
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<div className="flex flex-col gap-y-4">
<Skeleton className="h-2.5 w-full rounded-full" />
<div className="flex flex-col gap-y-2">
{STATUS_ROWS.slice(0, 3).map((row) => (
<Skeleton key={row.key} className="h-5 w-full" />
))}
</div>
</div>
) : data.total === 0 ? (
<div className="flex flex-1 flex-col gap-y-4">
<StatusBar segments={[]} label={_(msg`No documents in this period`)} />
<p className="flex flex-1 items-center justify-center text-center text-muted-foreground text-sm">
<Trans>No documents in this period</Trans>
</p>
</div>
) : (
<div className="flex flex-col gap-y-2">
<StatusBar segments={rows} label={_(msg`Document status distribution`)} />
<ul className="flex flex-col divide-y divide-border">
{rows.map((row) => (
<li key={row.key} className="flex items-center justify-between gap-x-3 py-2.5 text-sm">
<div className="flex min-w-0 items-center gap-x-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
aria-hidden="true"
/>
<span className="truncate text-foreground">{_(row.label)}</span>
</div>
<div className="flex shrink-0 items-baseline gap-x-2 tabular-nums">
<span className="font-medium text-foreground" data-testid={`analytics-status-${row.key}`}>
{row.count.toLocaleString(i18n.locale)}
</span>
<span className="w-10 text-right text-muted-foreground">{row.percent}%</span>
</div>
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
);
};
type StatusBarProps = {
segments: Array<{ key: string; percent: number; color: string }>;
label: string;
};
/**
* Stacked horizontal bar. Segment widths come from the largest-remainder
* percentages so they always add up to the full width; an empty list renders
* the muted track on its own.
*/
const StatusBar = ({ segments, label }: StatusBarProps) => {
return (
<div className="flex h-2.5 w-full gap-px overflow-hidden rounded-full bg-muted" role="img" aria-label={label}>
{segments.map((segment) => (
<div
key={segment.key}
className="h-full"
style={{ width: `${segment.percent}%`, backgroundColor: segment.color }}
/>
))}
</div>
);
};
type StatusKey = 'completed' | 'pending' | 'draft' | 'rejected' | 'cancelled';
type StatusRow = {
key: StatusKey;
label: MessageDescriptor;
color: string;
};
/**
* Single source of truth for status colours so the bar and the legend cannot drift.
*/
const STATUS_ROWS: StatusRow[] = [
{ key: 'completed', label: msg`Completed`, color: 'hsl(var(--primary))' },
{ key: 'pending', label: msg`Pending`, color: '#f59e0b' },
{ key: 'rejected', label: msg`Rejected`, color: '#ef4444' },
{ key: 'cancelled', label: msg`Cancelled`, color: '#f97316' },
{ key: 'draft', label: msg`Draft`, color: 'hsl(var(--muted-foreground) / 0.35)' },
];
/**
* Assign integer percentages to the non-zero rows using largest-remainder
* allocation so the values always sum to exactly 100, with every non-zero row
* shown as at least 1%.
*/
const allocatePercentages = <T extends { count: number }>(rows: T[]): Array<T & { percent: number }> => {
const visibleRows = rows.filter((row) => row.count > 0);
const total = visibleRows.reduce((sum, row) => sum + row.count, 0);
if (total === 0) {
return [];
}
const allocations = visibleRows.map((row, index) => {
const exact = (row.count / total) * 100;
const floored = Math.floor(exact);
return { index, percent: floored, remainder: exact - floored };
});
let remaining = 100 - allocations.reduce((sum, allocation) => sum + allocation.percent, 0);
const byRemainder = [...allocations].sort((a, b) => b.remainder - a.remainder || a.index - b.index);
for (const allocation of byRemainder) {
if (remaining <= 0) {
break;
}
allocation.percent += 1;
remaining -= 1;
}
// Every non-zero row must display at least 1%; take the difference from the largest rows.
const byPercentDesc = [...allocations].sort((a, b) => b.percent - a.percent || a.index - b.index);
for (const allocation of allocations) {
if (allocation.percent > 0) {
continue;
}
allocation.percent = 1;
const donor = byPercentDesc.find((candidate) => candidate !== allocation && candidate.percent > 1);
if (donor) {
donor.percent -= 1;
}
}
return visibleRows.map((row, index) => ({ ...row, percent: allocations[index].percent }));
};
@@ -1,145 +0,0 @@
import type { TGetTeamAnalyticsTemplateUsageResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { FileTextIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import type { AnalyticsQueryResult } from '~/utils/analytics';
import { formatRelativeDate } from '~/utils/analytics';
import { AnalyticsQueryError } from './analytics-query-error';
/** The template shape shared by the team and organisation procedures. */
export type AnalyticsTemplate = TGetTeamAnalyticsTemplateUsageResponse['templates'][number];
export type AnalyticsTemplateUsageCardProps<TTemplate extends AnalyticsTemplate> = {
query: AnalyticsQueryResult<{ templates: TTemplate[] }>;
/** Where the template title links to. Return null to render a plain title. */
getTemplateHref: (template: TTemplate) => string | null;
/** Extra meta shown before the "Updated ..." label, e.g. the owning team name. */
renderTemplateMeta?: (template: TTemplate) => ReactNode;
/** Link for the "View templates" button in the empty state. Omitted when there is no single templates page. */
templatesHref?: string;
className?: string;
};
export const AnalyticsTemplateUsageCard = <TTemplate extends AnalyticsTemplate>({
query,
getTemplateHref,
renderTemplateMeta,
templatesHref,
className,
}: AnalyticsTemplateUsageCardProps<TTemplate>) => {
const { i18n } = useLingui();
const { data, isLoading, isError, refetch } = query;
return (
<Card className={className} data-testid="analytics-template-usage">
<CardHeader>
<CardTitle>
<Trans>Template usage</Trans>
</CardTitle>
<CardDescription>
<Trans>Documents created from templates</Trans>
</CardDescription>
</CardHeader>
<CardContent>
{isError ? (
<AnalyticsQueryError onRetry={refetch} />
) : isLoading || !data ? (
<ul className="flex flex-col gap-y-3">
{Array.from({ length: 3 }, (_, index) => (
<li key={index} className="flex items-center gap-x-3">
<Skeleton className="h-4 w-5" />
<Skeleton className="h-9 w-9 shrink-0 rounded-md" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-4 w-16" />
</li>
))}
</ul>
) : data.templates.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<FileTextIcon className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
</div>
<p className="max-w-sm text-muted-foreground text-sm">
<Trans>No documents were created from templates in this period</Trans>
</p>
{templatesHref && (
<Button variant="outline" size="sm" asChild>
<Link to={templatesHref}>
<Trans>View templates</Trans>
</Link>
</Button>
)}
</div>
) : (
<ol className="flex flex-col divide-y divide-border">
{data.templates.map((template, index) => {
const href = template.title === null ? null : getTemplateHref(template);
const meta = renderTemplateMeta?.(template);
return (
<li
key={template.id}
className="flex items-center gap-x-3 py-3 first:pt-0 last:pb-0"
data-testid="analytics-template-row"
>
<span className="w-5 shrink-0 text-muted-foreground text-xs tabular-nums" aria-hidden="true">
{index + 1}
</span>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
<FileTextIcon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<div className="flex min-w-0 flex-1 flex-col">
{template.title === null ? (
<span className="truncate text-muted-foreground text-sm">
<Trans>Unavailable template</Trans>
</span>
) : href !== null ? (
<Link to={href} className="truncate font-medium text-foreground text-sm hover:underline">
{template.title}
</Link>
) : (
<span className="truncate font-medium text-foreground text-sm">{template.title}</span>
)}
{(meta || template.updatedAt !== null) && (
<span className="truncate text-muted-foreground text-xs">
{meta}
{meta && template.updatedAt !== null && ' · '}
{template.updatedAt !== null && (
<Trans>Updated {formatRelativeDate(template.updatedAt, i18n.locale)}</Trans>
)}
</span>
)}
</div>
<span className="shrink-0 rounded-md border bg-muted px-2 py-0.5 font-medium text-foreground text-xs tabular-nums">
<Plural value={template.count} one="# use" other="# uses" />
</span>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
);
};
@@ -599,10 +599,18 @@ export const AppCommandMenu = ({ open, onOpenChange }: AppCommandMenuProps) => {
isVisibleCountCapped ? (
<Trans>{formatChipCount(totalVisibleCount, isVisibleCountCapped)} results</Trans>
) : (
<Plural value={totalVisibleCount} one="# result" other="# results" />
<Plural
value={totalVisibleCount}
one="# result"
other="# results"
/>
)
) : (
<Plural value={totalVisibleCount} one="# item" other="# items" />
<Plural
value={totalVisibleCount}
one="# item"
other="# items"
/>
)}
</span>
</div>
@@ -1,9 +1,6 @@
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
@@ -25,7 +22,6 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
const { organisations } = useSession();
const currentTeam = useOptionalCurrentTeam();
const currentOrganisation = useOptionalCurrentOrganisation();
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
{
@@ -41,19 +37,18 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
};
const menuNavigationLinks = useMemo(() => {
const navigationTeam =
currentTeam ??
(organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null);
let teamUrl = currentTeam?.url || null;
if (!navigationTeam) {
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
return [
{
href: '/inbox',
text: t`Inbox`,
},
...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }]
: []),
{
href: '/settings/profile',
text: t`Settings`,
@@ -61,8 +56,6 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
];
}
const teamUrl = navigationTeam.url;
return [
{
href: `/t/${teamUrl}/documents`,
@@ -76,15 +69,12 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
href: '/inbox',
text: t`Inbox`,
},
...(canExecuteTeamAction('MANAGE_TEAM', navigationTeam.currentTeamRole)
? [{ href: formatAnalyticsPath(teamUrl), text: t`Analytics` }]
: []),
{
href: '/settings/profile',
text: t`Settings`,
},
];
}, [currentTeam, currentOrganisation, organisations, t]);
}, [currentTeam, organisations]);
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
@@ -0,0 +1,66 @@
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DocumentStatus } from '@prisma/client';
import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: TRecipientLite;
documentStatus: DocumentStatus;
};
export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard();
const { _ } = useLingui();
const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => {
if (!signingToken) {
return;
}
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
});
};
return (
<div
className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': signingToken,
})}
role={signingToken ? 'button' : undefined}
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
onClick={onRecipientClick}
>
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div
className="text-muted-foreground text-sm"
title={signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined}
>
<p>{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
</div>
);
}
@@ -261,7 +261,9 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex items-center gap-x-4">
<DocumentSigningAttachmentsPopover envelopeId={document.envelopeId} token={recipient.token} />
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
{documentMeta.allowDocumentRejection && (
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
)}
</div>
</div>
@@ -63,6 +63,10 @@ export const DocumentSigningPageViewV2 = () => {
onDocumentRejected,
} = useEmbedSigningContext() || {};
// Both the document settings and the embed host (if any) must allow rejection.
const isDocumentRejectionAllowed =
envelope.type === EnvelopeType.DOCUMENT && envelope.documentMeta.allowDocumentRejection && allowDocumentRejection;
const { t } = useLingui();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
@@ -193,7 +197,7 @@ export const DocumentSigningPageViewV2 = () => {
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
{isDocumentRejectionAllowed && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
@@ -2,7 +2,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { Input } from '@documenso/ui/primitives/input';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useQueryStates } from 'nuqs';
import { useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { documentsSearchParams } from '~/utils/documents-search-params';
@@ -10,26 +10,16 @@ import { documentsSearchParams } from '~/utils/documents-search-params';
export const DocumentSearch = () => {
const { _ } = useLingui();
const [{ query }, setSearchParams] = useQueryStates(
{
query: documentsSearchParams.query,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
const [searchTerm, setSearchTerm] = useState(query ?? '');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm !== (query ?? '')) {
// Reset pagination so a new search never lands on an empty page.
void setSearchParams({
query: debouncedSearchTerm || null,
page: null,
});
void setQuery(debouncedSearchTerm || null);
}
}, [debouncedSearchTerm, query, setSearchParams]);
}, [debouncedSearchTerm, query, setQuery]);
return (
<Input
@@ -16,7 +16,7 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { canAccessTeamDocument, DocumentSignatureType, extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
@@ -97,7 +97,7 @@ export const ZAddSettingsFormSchema = z.object({
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || isHttpUrl(value), {
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
language: z
@@ -110,6 +110,7 @@ export const ZAddSettingsFormSchema = z.object({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
allowDocumentRejection: z.boolean(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
}),
@@ -200,6 +201,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
allowDocumentRejection: envelope.documentMeta.allowDocumentRejection,
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
reminderSettings: envelope.documentMeta?.reminderSettings ?? null,
},
@@ -249,6 +251,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
message,
subject,
emailReplyTo,
allowDocumentRejection,
envelopeExpirationPeriod,
reminderSettings,
} = data.meta;
@@ -278,6 +281,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
allowDocumentRejection,
envelopeExpirationPeriod,
reminderSettings,
},
@@ -465,6 +469,52 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
/>
)}
<FormField
control={form.control}
name="meta.allowDocumentRejection"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allow Document Rejection</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Controls whether recipients can reject the document from the signing page.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select
value={field.value ? 'true' : 'false'}
onValueChange={(value) => field.onChange(value === 'true')}
>
<SelectTrigger className="bg-background" data-testid="allow-document-rejection-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{settings.allowConfigureDateFormat && (
<FormField
control={form.control}
@@ -87,7 +87,11 @@ export const EnvelopeSignerHeader = () => {
const MobileDropdownMenu = () => {
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
const { allowDocumentRejection } = useEmbedSigningContext() || {};
const { allowDocumentRejection = true } = useEmbedSigningContext() || {};
// Both the document settings and the embed host (if any) must allow rejection.
const isDocumentRejectionAllowed =
envelope.type === EnvelopeType.DOCUMENT && envelope.documentMeta.allowDocumentRejection && allowDocumentRejection;
return (
<DropdownMenu>
@@ -113,7 +117,7 @@ const MobileDropdownMenu = () => {
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
{isDocumentRejectionAllowed && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
@@ -31,8 +31,6 @@ type FilterPillCommonProps = {
enableSearch?: boolean;
searchPlaceholder?: string;
loading?: boolean;
/** Whether the selection can be removed. Defaults to true. */
clearable?: boolean;
testId?: string;
};
@@ -63,7 +61,7 @@ export type FilterPillProps = FilterPillSingleProps | FilterPillMultipleProps;
* selections followed by a "+N more" chip.
*/
export const FilterPill = (props: FilterPillProps) => {
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, clearable = true, testId } = props;
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, testId } = props;
const [open, setOpen] = useState(false);
@@ -86,7 +84,7 @@ export const FilterPill = (props: FilterPillProps) => {
return;
}
props.onChange(nextValue === props.value && clearable ? null : nextValue);
props.onChange(nextValue === props.value ? null : nextValue);
setOpen(false);
};
@@ -170,7 +168,7 @@ export const FilterPill = (props: FilterPillProps) => {
))}
</CommandGroup>
{hasSelection && clearable && (
{hasSelection && (
<>
<CommandSeparator />
<CommandGroup>
@@ -7,10 +7,9 @@ export type CardMetricProps = {
value?: string | number;
className?: string;
children?: React.ReactNode;
testId?: string;
};
export const CardMetric = ({ icon: Icon, title, value, className, children, testId }: CardMetricProps) => {
export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => {
return (
<div
className={cn(
@@ -30,7 +29,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children, test
</div>
{children || (
<p className="mt-auto font-semibold text-4xl text-foreground leading-8" data-testid={testId}>
<p className="mt-auto font-semibold text-4xl text-foreground leading-8">
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
)}
@@ -6,13 +6,9 @@ import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import {
canAccessOrganisationAnalytics,
canExecuteOrganisationAction,
formatOrganisationAnalyticsPath,
} from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction, formatAnalyticsPath } from '@documenso/lib/utils/teams';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
import { cn } from '@documenso/ui/lib/utils';
@@ -66,13 +62,6 @@ export const OrgMenuSwitcher = () => {
const canAccessTeamSettings = currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole);
// Team analytics take precedence when in a team context, the team page links to organisation analytics.
const analyticsPath = canAccessTeamSettings
? formatAnalyticsPath(currentTeam.url)
: currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
? formatOrganisationAnalyticsPath(currentOrganisation.url)
: null;
// Use hovered org for teams display if available,
// otherwise use current team's org if in a team,
// finally fallback to selected org
@@ -282,14 +271,6 @@ export const OrgMenuSwitcher = () => {
</Link>
</DropdownMenuItem>
{analyticsPath && (
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link to={analyticsPath}>
<Trans>Analytics</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem className="px-4 py-2 text-muted-foreground" asChild>
<Link
to={
@@ -0,0 +1,37 @@
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { Trans } from '@lingui/react/macro';
import { ChevronLeft, Loader } from 'lucide-react';
import { Link } from 'react-router';
export default function DocumentEditSkeleton() {
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<Link to="/" className="flex grow-0 items-center text-documenso-700 hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
<h1 className="mt-4 grow-0 truncate font-semibold text-2xl md:text-3xl">
<Trans>Loading Document...</Trans>
</h1>
<div className="flex h-10 items-center">
<Skeleton className="my-6 h-4 w-24 rounded-2xl" />
</div>
<div className="mt-4 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
<div className="col-span-12 rounded-xl border-2 border-border bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7 dark:bg-background">
<div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
<Loader className="h-12 w-12 animate-spin text-documenso" />
<p className="mt-4 text-muted-foreground">
<Trans>Loading document...</Trans>
</p>
</div>
</div>
<div className="col-span-12 rounded-xl border-2 border-border bg-background before:rounded-xl lg:col-span-6 xl:col-span-5" />
</div>
</div>
);
}
@@ -1,5 +1,4 @@
import { RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
import { cn } from '@documenso/ui/lib/utils';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
const ZIndexes: { [key: string]: string } = {
@@ -15,10 +14,9 @@ export type StackAvatarProps = {
zIndex?: string;
fallbackText?: string;
type: RecipientStatusType;
className?: string;
};
export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className }: StackAvatarProps) => {
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
let classes = '';
let zIndexClass = '';
const firstClass = first ? '' : '-ml-3';
@@ -48,14 +46,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className
}
return (
<Avatar
className={cn(
zIndexClass,
firstClass,
'h-10 w-10 border-2 border-white border-solid dark:border-border',
className,
)}
>
<Avatar className={` ${zIndexClass} ${firstClass} h-10 w-10 border-2 border-white border-solid dark:border-border`}>
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
</Avatar>
);
@@ -1,33 +1,16 @@
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import {
getExtraRecipientsType,
getRecipientType,
RecipientStatusType,
} from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRecipientType, RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DocumentStatus } from '@prisma/client';
import type { LucideIcon } from 'lucide-react';
import {
CheckIcon,
CircleCheckIcon,
CircleDashedIcon,
CircleXIcon,
ClockIcon,
CopyIcon,
MailOpenIcon,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { DocumentStatus } from '@prisma/client';
import { useMemo } from 'react';
import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
@@ -44,238 +27,125 @@ export const StackAvatarsWithTooltip = ({
}: StackAvatarsWithTooltipProps) => {
const { _ } = useLingui();
const sections = useMemo(() => {
const groups = groupRecipientsByStatus(recipients);
const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
);
return RECIPIENT_STATUS_SECTIONS.map((section) => ({
...section,
recipients: groups[section.type],
})).filter((section) => section.recipients.length > 0);
const openedRecipients = recipients.filter((recipient) => getRecipientType(recipient) === RecipientStatusType.OPENED);
const completedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.COMPLETED,
);
const uncompletedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.UNSIGNED,
);
const rejectedRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
);
return [
...rejectedRecipients.sort((a, b) => a.id - b.id),
...otherRecipients.sort((a, b) => {
return a.id - b.id;
}),
];
}, [recipients]);
const canCopySigningLink = documentStatus === DocumentStatus.PENDING;
return (
<PopoverHover
trigger={children || <RecipientAvatarStack recipients={recipients} />}
trigger={children || <StackAvatars recipients={sortedRecipients} />}
contentProps={{
className:
'max-h-[var(--radix-popover-content-available-height)] w-72 divide-y divide-border/50 overflow-y-auto p-0 text-sm',
className: 'flex flex-col gap-y-5 py-2',
side: position,
// Keep clear of the sticky app header (h-16, z-[60]) which paints above popovers.
collisionPadding: { top: 72, bottom: 8, left: 8, right: 8 },
// Opened via hover, so don't steal focus from wherever the user was.
onOpenAutoFocus: (event) => event.preventDefault(),
}}
>
{sections.map((section) => (
<div key={section.type} className="px-3 py-2">
<div className={cn('flex items-center gap-1.5 font-medium text-xs', section.className)}>
<section.icon className="h-3.5 w-3.5 shrink-0" strokeWidth={2} />
<span>{_(section.label)}</span>
<span>{section.recipients.length}</span>
</div>
<div className="mt-1">
{section.recipients.map((recipient) => (
<RecipientRow
{completedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Completed</Trans>
</h1>
{completedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
recipient={recipient}
signingToken={canCopySigningLink && section.hasSigningLink ? recipient.token : null}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
))}
</div>
<div>
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
))}
)}
{rejectedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Rejected</Trans>
</h1>
{rejectedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email || recipient.name}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Waiting</Trans>
</h1>
{waitingRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
{openedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Opened</Trans>
</h1>
{openedRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
{uncompletedRecipients.length > 0 && (
<div>
<h1 className="font-medium text-base">
<Trans>Uncompleted</Trans>
</h1>
{uncompletedRecipients.map((recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} documentStatus={documentStatus} />
))}
</div>
)}
</PopoverHover>
);
};
type RecipientRowProps = {
recipient: TRecipientLite;
signingToken: string | null;
};
const RecipientRow = ({ recipient, signingToken }: RecipientRowProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [, copy] = useCopyToClipboard();
const [isCopied, setIsCopied] = useState(false);
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => clearTimeout(copiedTimeoutRef.current ?? undefined), []);
const onCopySigningLink = () => {
if (!signingToken) {
return;
}
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
setIsCopied(true);
clearTimeout(copiedTimeoutRef.current ?? undefined);
copiedTimeoutRef.current = setTimeout(() => setIsCopied(false), COPIED_INDICATOR_DURATION_MS);
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The signing link has been copied to your clipboard.`),
});
});
};
const content = (
<>
<StackAvatar
first={true}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
className="h-6 w-6 shrink-0 border-0 text-[10px]"
/>
<div className="min-w-0 flex-1 text-xs leading-snug">
<p className="truncate text-foreground">{recipient.email || recipient.name}</p>
<p className="truncate text-muted-foreground">{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
</>
);
if (!signingToken) {
return <div className={recipientRowClassName}>{content}</div>;
}
return (
<button
type="button"
className={cn(
recipientRowClassName,
'group w-[calc(100%+1rem)] cursor-pointer text-left transition-colors duration-300 hover:bg-muted',
)}
title={_(msg`Click to copy signing link for sending to recipient`)}
onClick={onCopySigningLink}
>
{content}
{isCopied ? (
<CheckIcon className="h-3 w-3 shrink-0 text-green-600 dark:text-green-400" />
) : (
<CopyIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
)}
</button>
);
};
const RecipientAvatarStack = ({ recipients }: { recipients: TRecipientLite[] }) => {
const sortedRecipients = useMemo(() => {
const byId = (a: TRecipientLite, b: TRecipientLite) => a.id - b.id;
const rejected = recipients.filter((r) => getRecipientType(r) === RecipientStatusType.REJECTED);
const others = recipients.filter((r) => getRecipientType(r) !== RecipientStatusType.REJECTED);
return [...rejected.sort(byId), ...others.sort(byId)];
}, [recipients]);
const visibleRecipients = sortedRecipients.slice(0, MAX_VISIBLE_AVATARS);
const hiddenRecipients = sortedRecipients.slice(MAX_VISIBLE_AVATARS);
return (
<>
{visibleRecipients.map((recipient, index) => {
const isOverflowSlot = index === MAX_VISIBLE_AVATARS - 1 && hiddenRecipients.length > 0;
const zIndex = String(50 - index * 10);
if (isOverflowSlot) {
return (
<StackAvatar
key="extra-recipients"
first={index === 0}
zIndex={zIndex}
type={getExtraRecipientsType(sortedRecipients.slice(index))}
fallbackText={`+${hiddenRecipients.length + 1}`}
/>
);
}
return (
<StackAvatar
key={recipient.id}
first={index === 0}
zIndex={zIndex}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
);
})}
</>
);
};
const groupRecipientsByStatus = (recipients: TRecipientLite[]) => {
const groups: Record<RecipientStatusType, TRecipientLite[]> = {
[RecipientStatusType.COMPLETED]: [],
[RecipientStatusType.REJECTED]: [],
[RecipientStatusType.WAITING]: [],
[RecipientStatusType.OPENED]: [],
[RecipientStatusType.UNSIGNED]: [],
};
for (const recipient of recipients) {
groups[getRecipientType(recipient)].push(recipient);
}
return groups;
};
const MAX_VISIBLE_AVATARS = 5;
const COPIED_INDICATOR_DURATION_MS = 2000;
const recipientRowClassName = '-mx-2 flex items-center gap-2 rounded-md px-2 py-1';
type RecipientStatusSection = {
type: RecipientStatusType;
label: MessageDescriptor;
icon: LucideIcon;
className: string;
/** Whether recipients in this section still need to sign, so a signing link can be copied. */
hasSigningLink: boolean;
};
const RECIPIENT_STATUS_SECTIONS: RecipientStatusSection[] = [
{
type: RecipientStatusType.COMPLETED,
label: msg`Completed`,
icon: CircleCheckIcon,
className: 'text-green-600 dark:text-green-400',
hasSigningLink: false,
},
{
type: RecipientStatusType.REJECTED,
label: msg`Rejected`,
icon: CircleXIcon,
className: 'text-red-600 dark:text-red-400',
hasSigningLink: false,
},
{
type: RecipientStatusType.WAITING,
label: msg`Waiting`,
icon: ClockIcon,
className: 'text-blue-600 dark:text-blue-400',
hasSigningLink: true,
},
{
type: RecipientStatusType.OPENED,
label: msg`Opened`,
icon: MailOpenIcon,
className: 'text-amber-600 dark:text-amber-400',
hasSigningLink: true,
},
{
type: RecipientStatusType.UNSIGNED,
label: msg`Uncompleted`,
icon: CircleDashedIcon,
className: 'text-muted-foreground',
hasSigningLink: true,
},
];
@@ -0,0 +1,41 @@
import { getExtraRecipientsType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { StackAvatar } from './stack-avatar';
export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
const renderStackAvatars = (recipients: TRecipientLite[]) => {
const zIndex = 50;
const itemsToRender = recipients.slice(0, 5);
const remainingItems = recipients.length - itemsToRender.length;
return itemsToRender.map((recipient, index: number) => {
const first = index === 0;
if (index === 4 && remainingItems > 0) {
return (
<StackAvatar
key="extra-recipient"
first={first}
zIndex={String(zIndex - index * 10)}
type={getExtraRecipientsType(recipients.slice(4))}
fallbackText={`+${remainingItems + 1}`}
/>
);
}
return (
<StackAvatar
key={recipient.id}
first={first}
zIndex={String(zIndex - index * 10)}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
);
});
};
return <>{renderStackAvatars(recipients)}</>;
}
@@ -1,42 +0,0 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useQueryStates } from 'nuqs';
import { useEffect, useState } from 'react';
import { templatesSearchParams } from '~/utils/templates-search-params';
export const TemplateSearch = () => {
const { _ } = useLingui();
const [{ query }, setSearchParams] = useQueryStates(
{
query: templatesSearchParams.query,
page: templatesSearchParams.page,
},
{ history: 'push' },
);
const [searchTerm, setSearchTerm] = useState(query ?? '');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm !== (query ?? '')) {
void setSearchParams({
query: debouncedSearchTerm || null,
page: null,
});
}
}, [debouncedSearchTerm, query, setSearchParams]);
return (
<Input
type="search"
placeholder={_(msg`Search templates...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
data-testid="templates-search-input"
/>
);
};
@@ -0,0 +1,75 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { VerifiedIcon } from '@documenso/ui/icons/verified';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { File, User2 } from 'lucide-react';
export type UserProfileSkeletonProps = {
className?: string;
user: {
name: string;
url: string;
};
rows?: number;
};
export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSkeletonProps) => {
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
return (
<div className={cn('flex flex-col items-center rounded-xl bg-neutral-100 p-4 dark:bg-background', className)}>
<div className="inline-block max-w-full truncate rounded-md border border-border bg-background px-2.5 py-1.5 text-muted-foreground text-sm lowercase">
{baseUrl.host}/u/{user.url}
</div>
<div className="mt-4">
<div className="rounded-full bg-primary/10 p-1.5">
<div className="flex h-20 w-20 items-center justify-center rounded-full border-2 bg-background">
<User2 className="h-12 w-12 text-[hsl(228,10%,90%)]" />
</div>
</div>
</div>
<div className="mt-6">
<div className="flex items-center justify-center gap-x-2">
<h2 className="max-w-[12rem] truncate font-semibold text-2xl">{user.name}</h2>
<VerifiedIcon className="h-8 w-8 text-primary" />
</div>
<div className="mx-auto mt-4 h-2 w-52 rounded-full bg-neutral-300 dark:bg-foreground/30" />
<div className="mx-auto mt-2 h-2 w-36 rounded-full bg-neutral-200 dark:bg-foreground/20" />
</div>
<div className="mt-8 w-full">
<div className="divide-y-2 divide-neutral-200 overflow-hidden rounded-lg border-2 border-neutral-200 dark:divide-foreground/30 dark:border-foreground/30">
<div className="bg-neutral-50 p-4 font-medium text-muted-foreground dark:bg-foreground/20">
<Trans>Documents</Trans>
</div>
{Array(rows)
.fill(0)
.map((_, index) => (
<div key={index} className="flex items-center justify-between gap-x-6 bg-background p-4">
<div className="flex items-center gap-x-2">
<File className="h-8 w-8 text-muted-foreground/80" strokeWidth={1.5} />
<div className="space-y-2">
<div className="h-1.5 w-24 rounded-full bg-neutral-300 md:w-36 dark:bg-foreground/30" />
<div className="h-1.5 w-16 rounded-full bg-neutral-200 md:w-24 dark:bg-foreground/20" />
</div>
</div>
<div className="flex-shrink-0">
<Button type="button" size="sm" className="pointer-events-none w-32">
<Trans>Sign</Trans>
</Button>
</div>
</div>
))}
</div>
</div>
</div>
);
};
@@ -1,4 +1,4 @@
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
@@ -14,26 +14,13 @@ import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableStatusFilterProps = {
/**
* Per-status document counts, shown next to each option. When omitted no
* counts are rendered.
*/
stats?: TFindDocumentsInternalResponse['stats'];
/**
* The statuses available for selection. Defaults to every status that
* makes sense for the documents page.
*/
statuses?: ExtendedDocumentStatus[];
stats: TFindDocumentsInternalResponse['stats'];
};
export const DocumentsTableStatusFilter = ({
stats,
statuses = SELECTABLE_STATUSES,
}: DocumentsTableStatusFilterProps) => {
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
const { _ } = useLingui();
const organisation = useOptionalCurrentOrganisation();
const organisation = useCurrentOrganisation();
const [{ status }, setSearchParams] = useQueryStates(
{
@@ -45,14 +32,14 @@ export const DocumentsTableStatusFilter = ({
const selectableStatuses = useMemo(
() =>
statuses.filter((value) => {
if (organisation?.type === OrganisationType.PERSONAL) {
SELECTABLE_STATUSES.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
return true;
}),
[organisation?.type, statuses],
[organisation.type],
);
const selectedStatus = useMemo(
@@ -78,22 +65,20 @@ export const DocumentsTableStatusFilter = ({
options={selectableStatuses.map((value) => ({
value,
label: <DocumentStatus status={value} />,
trailing: stats ? formatStatsCount(stats[value]) : undefined,
trailing: formatStatsCount(stats[value]),
}))}
testId="documents-table-status-filter"
/>
{/* Visually hidden document counts, for screen readers and tests. */}
{stats && (
<span className="sr-only" data-testid="documents-status-counts">
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
<span key={value}>
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
</span>
))}
</span>
)}
<span className="sr-only" data-testid="documents-status-counts">
{[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
<span key={value}>
{_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
<span data-testid={`documents-status-count-${value}`}>{stats[value]}</span>
</span>
))}
</span>
</>
);
};
@@ -16,17 +16,22 @@ import { Trans } from '@lingui/react/macro';
import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useQueryStates } from 'nuqs';
import { useMemo, useTransition } from 'react';
import { useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { DocumentStatus } from '~/components/general/document/document-status';
import { useOptionalCurrentTeam } from '~/providers/team';
import { inboxSearchParams, resolveInboxStatus } from '~/utils/inbox-search-params';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
export type DocumentsTableProps = {
data?: TFindInboxResponse;
isLoading?: boolean;
isLoadingError?: boolean;
};
type DocumentsTableRow = TFindInboxResponse['data'][number];
export const InboxTable = () => {
@@ -35,24 +40,17 @@ export const InboxTable = () => {
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [findInboxSearchParams] = useQueryStates(inboxSearchParams, {
history: 'push',
});
const status = resolveInboxStatus(findInboxSearchParams.status);
const query = findInboxSearchParams.query ?? '';
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({
page: Math.max(findInboxSearchParams.page ?? 1, 1),
perPage: Math.min(Math.max(findInboxSearchParams.perPage ?? 10, 1), 100),
query: query || undefined,
status,
page: page || 1,
perPage: perPage || 10,
});
const hasSearchQuery = query.trim().length > 0;
const columns = useMemo(() => {
return [
{
@@ -125,20 +123,7 @@ export const InboxTable = () => {
emptyState={
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<p>
{match({ hasSearchQuery, status })
.with({ hasSearchQuery: true }, () => <Trans>No documents match your search</Trans>)
.with({ status: DocumentStatusEnum.COMPLETED }, () => (
<Trans>Documents that you have completed will appear here</Trans>
))
.with({ status: DocumentStatusEnum.REJECTED }, () => (
<Trans>Documents that have been rejected will appear here</Trans>
))
.with({ status: DocumentStatusEnum.CANCELLED }, () => (
<Trans>Documents that have been cancelled will appear here</Trans>
))
.otherwise(() => (
<Trans>Documents that require your attention will appear here</Trans>
))}
<Trans>Documents that require your attention will appear here</Trans>
</p>
</div>
}
@@ -170,10 +170,7 @@ export const TemplatesTableActionDropdown = ({
onOpenChange={setRenameDialogOpen}
envelopeType="template"
onSuccess={async () => {
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
await trpcUtils.template.findTemplates.invalidate();
}}
/>
</DropdownMenu>
@@ -1,61 +0,0 @@
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { trpc } from '@documenso/trpc/react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { UserIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { templatesSearchParams } from '~/utils/templates-search-params';
type TemplatesTableOwnerFilterProps = {
teamId: number;
};
export const TemplatesTableOwnerFilter = ({ teamId }: TemplatesTableOwnerFilterProps) => {
const { _ } = useLingui();
const isMounted = useIsMounted();
const [{ ownerIds }, setSearchParams] = useQueryStates(
{
ownerIds: templatesSearchParams.ownerIds,
page: templatesSearchParams.page,
},
{ history: 'push' },
);
const selectedOwnerIds = (ownerIds ?? []).map((ownerId) => ownerId.toString());
const { data, isLoading } = trpc.team.member.getMany.useQuery({
teamId,
});
const options = (data ?? []).map((member) => ({
label: member.name ?? member.email,
value: member.userId.toString(),
}));
const onChange = (newOwnerIds: string[]) => {
void setSearchParams({
ownerIds: newOwnerIds.length > 0 ? newOwnerIds.map(Number) : null,
page: null,
});
};
return (
<FilterPill
multiple
icon={UserIcon}
label={<Trans>Owner</Trans>}
value={selectedOwnerIds}
onChange={onChange}
options={options}
enableSearch
searchPlaceholder={_(msg`Search members...`)}
loading={!isMounted || isLoading}
testId="templates-table-owner-filter"
/>
);
};
@@ -1,42 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { Building2Icon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { FilterPill } from '~/components/general/filter-pill';
import { TEMPLATES_VIEW_VALUES, templatesSearchParams } from '~/utils/templates-search-params';
const VIEW_OPTIONS = [
{ value: 'team', label: <Trans>Team</Trans> },
{ value: 'organisation', label: <Trans>Organisation</Trans> },
];
export const TemplatesTableViewFilter = () => {
const [{ view }, setSearchParams] = useQueryStates(
{
view: templatesSearchParams.view,
ownerIds: templatesSearchParams.ownerIds,
page: templatesSearchParams.page,
},
{ history: 'push' },
);
const onChange = (newView: string | null) => {
// The owner filter only applies to the team view, so drop it on any view change.
void setSearchParams({
view: TEMPLATES_VIEW_VALUES.find((value) => value === newView) ?? null,
ownerIds: null,
page: null,
});
};
return (
<FilterPill
icon={Building2Icon}
label={<Trans>View</Trans>}
value={view}
onChange={onChange}
options={VIEW_OPTIONS}
testId="templates-table-view-filter"
/>
);
};
@@ -2,11 +2,8 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { InboxIcon } from 'lucide-react';
import { DocumentSearch } from '~/components/general/document/document-search';
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
import { InboxTable } from '~/components/tables/inbox-table';
import { INBOX_SELECTABLE_STATUSES } from '~/utils/inbox-search-params';
import { appMetaTags } from '~/utils/meta';
export function meta() {
@@ -29,14 +26,6 @@ export default function InboxPage() {
<OrganisationInvitations className="mt-4" />
</div>
<div className="mb-8 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<DocumentSearch />
</div>
<DocumentsTableStatusFilter statuses={INBOX_SELECTABLE_STATUSES} />
</div>
<InboxTable />
</div>
);
@@ -1,11 +1,7 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import {
canAccessOrganisationAnalytics,
canExecuteOrganisationAction,
formatOrganisationAnalyticsPath,
} from '@documenso/lib/utils/organisations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction, formatTeamUrl } from '@documenso/lib/utils/teams';
import type { TGetOrganisationSessionResponse } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
@@ -21,7 +17,6 @@ import {
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRight,
BarChart3Icon,
CalendarIcon,
MoreVerticalIcon,
PlusIcon,
@@ -119,22 +114,11 @@ export default function OrganisationSettingsTeamsPage() {
</p>
</div>
<div className="flex items-center gap-2">
{canAccessOrganisationAnalytics(organisation.currentOrganisationRole) && (
<Button variant="outline" asChild>
<Link to={formatOrganisationAnalyticsPath(organisation.url)}>
<BarChart3Icon className="mr-2 h-4 w-4" />
<Trans>Analytics</Trans>
</Link>
</Button>
)}
<Button asChild>
<Link to={`/o/${organisation.url}/settings/general`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
</div>
<Button asChild>
<Link to={`/o/${organisation.url}/settings/general`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -1,179 +0,0 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { formatAnalyticsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { trpc } from '@documenso/trpc/react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import { UsersIcon } from 'lucide-react';
import { redirect } from 'react-router';
import type { AnalyticsActivityRow } from '~/components/general/analytics/analytics-activity-table-card';
import { AnalyticsActivityTableCard } from '~/components/general/analytics/analytics-activity-table-card';
import { AnalyticsDocumentsOverTimeCard } from '~/components/general/analytics/analytics-documents-over-time-card';
import { AnalyticsHydrateFallback } from '~/components/general/analytics/analytics-hydrate-fallback';
import { AnalyticsNoActivityAlert } from '~/components/general/analytics/analytics-no-activity-alert';
import { AnalyticsOverviewCards } from '~/components/general/analytics/analytics-overview-cards';
import { AnalyticsPageHeader } from '~/components/general/analytics/analytics-page-header';
import { AnalyticsStatusBreakdownCard } from '~/components/general/analytics/analytics-status-breakdown-card';
import { AnalyticsTemplateUsageCard } from '~/components/general/analytics/analytics-template-usage-card';
import { resolveBrowserTimezone, useAnalyticsRange } from '~/utils/analytics';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/o.$orgUrl.analytics._index';
export function meta() {
return appMetaTags(msg`Analytics`);
}
/**
* Organisation analytics are restricted to organisation admins (not managers),
* matching the tRPC procedures.
*/
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
const organisation = await prisma.organisation.findFirst({
where: {
...buildOrganisationWhereQuery({
organisationId: undefined,
userId: session.user.id,
roles: [OrganisationMemberRole.ADMIN],
}),
url: params.orgUrl,
},
select: { id: true },
});
if (!organisation) {
throw redirect(`/o/${params.orgUrl}`);
}
return {};
}
/**
* The timezone is read from the browser so the analytics queries only run on the
* client, after hydration, with the correct day boundaries.
*/
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
await serverLoader();
return { timezone: resolveBrowserTimezone() };
}
clientLoader.hydrate = true as const;
export function HydrateFallback() {
return <AnalyticsHydrateFallback />;
}
export default function OrganisationAnalyticsPage({ loaderData }: Route.ComponentProps) {
const organisation = useCurrentOrganisation();
const { _ } = useLingui();
const { timezone } = loaderData;
const { value: range, rangeKey, setValue: setRange } = useAnalyticsRange();
// `from`/`to` are only present for custom ranges.
const queryInput = { organisationId: organisation.id, timezone, ...range };
const overviewQuery = trpc.organisation.analytics.getOverview.useQuery(queryInput);
const documentsOverTimeQuery = trpc.organisation.analytics.getDocumentsOverTime.useQuery(queryInput);
const statusBreakdownQuery = trpc.organisation.analytics.getStatusBreakdown.useQuery(queryInput);
const templateUsageQuery = trpc.organisation.analytics.getTemplateUsage.useQuery({
...queryInput,
limit: TEMPLATE_LIMIT,
});
const teamActivityQuery = trpc.organisation.analytics.getTeamActivity.useQuery(queryInput);
const hasNoActivity =
overviewQuery.isSuccess &&
documentsOverTimeQuery.isSuccess &&
overviewQuery.data.sent.current === 0 &&
overviewQuery.data.sent.previous === 0 &&
documentsOverTimeQuery.data.total === 0;
const teamRows: AnalyticsActivityRow[] = (teamActivityQuery.data?.teams ?? []).map((team) => ({
key: team.id,
avatar: { imageId: team.avatarImageId, fallback: team.name.slice(0, 1).toUpperCase() },
title: team.name,
subtitle: `/t/${team.url}`,
sent: team.sent,
completed: team.completed,
pending: team.pending,
completionRate: team.completionRate,
lastActiveAt: team.lastActiveAt,
href: formatAnalyticsPath(team.url),
}));
return (
<div>
<AnalyticsPageHeader
avatarImageId={organisation.avatarImageId}
name={organisation.name}
range={range}
onRangeChange={setRange}
/>
{hasNoActivity && (
<AnalyticsNoActivityAlert range={range.range} onShowLastYear={() => setRange({ range: '12m' })} />
)}
<div className="mt-6 flex flex-col gap-4">
<AnalyticsOverviewCards
query={overviewQuery}
entity={{
icon: UsersIcon,
title: <Trans>Teams</Trans>,
testId: 'analytics-teams',
select: (data) => data.teams,
}}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<AnalyticsDocumentsOverTimeCard range={range} query={documentsOverTimeQuery} className="lg:col-span-2" />
<AnalyticsStatusBreakdownCard query={statusBreakdownQuery} />
</div>
<AnalyticsTemplateUsageCard
query={templateUsageQuery}
getTemplateHref={(template) =>
template.team !== null && template.envelopeId !== null
? `${formatTemplatesPath(template.team.url)}/${template.envelopeId}`
: null
}
renderTemplateMeta={(template) => template.team?.name}
/>
<AnalyticsActivityTableCard
query={teamActivityQuery}
rows={teamRows}
rangeKey={rangeKey}
title={<Trans>Team activity</Trans>}
description={<Trans>Documents sent by each team in this period</Trans>}
columnLabel={<Trans>Team</Trans>}
renderSummary={(count, activeCount) => (
<>
<Plural value={count} one="# team" other="# teams" /> · <Trans>{activeCount} active this period</Trans>
</>
)}
renderShowing={(visibleCount, totalCount) => (
<Trans>
Showing {visibleCount} of {totalCount} teams
</Trans>
)}
emptyLabel={<Trans>No teams</Trans>}
searchPlaceholder={_(msg`Search teams`)}
noSearchResultsLabel={<Trans>No teams match your search</Trans>}
testIdPrefix="team"
/>
</div>
</div>
);
}
const TEMPLATE_LIMIT = 5;
@@ -39,6 +39,7 @@ export default function OrganisationSettingsDocumentPage() {
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
allowDocumentRejection,
aiFeaturesEnabled,
} = data;
@@ -46,6 +47,7 @@ export default function OrganisationSettingsDocumentPage() {
documentVisibility === null ||
documentLanguage === null ||
documentDateFormat === null ||
allowDocumentRejection === null ||
aiFeaturesEnabled === null
) {
throw new Error('Should not be possible.');
@@ -63,6 +65,7 @@ export default function OrganisationSettingsDocumentPage() {
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
delegateDocumentOwnership,
allowDocumentRejection,
aiFeaturesEnabled,
},
});
@@ -1,183 +0,0 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canAccessOrganisationAnalytics, formatOrganisationAnalyticsPath } from '@documenso/lib/utils/organisations';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction, formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { ArrowRightIcon, UsersIcon } from 'lucide-react';
import { Link, redirect } from 'react-router';
import type { AnalyticsActivityRow } from '~/components/general/analytics/analytics-activity-table-card';
import { AnalyticsActivityTableCard } from '~/components/general/analytics/analytics-activity-table-card';
import { AnalyticsDocumentsOverTimeCard } from '~/components/general/analytics/analytics-documents-over-time-card';
import { AnalyticsHydrateFallback } from '~/components/general/analytics/analytics-hydrate-fallback';
import { AnalyticsNoActivityAlert } from '~/components/general/analytics/analytics-no-activity-alert';
import { AnalyticsOverviewCards } from '~/components/general/analytics/analytics-overview-cards';
import { AnalyticsPageHeader } from '~/components/general/analytics/analytics-page-header';
import { AnalyticsStatusBreakdownCard } from '~/components/general/analytics/analytics-status-breakdown-card';
import { AnalyticsTemplateUsageCard } from '~/components/general/analytics/analytics-template-usage-card';
import { useCurrentTeam } from '~/providers/team';
import { resolveBrowserTimezone, useAnalyticsRange } from '~/utils/analytics';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/analytics._index';
export function meta() {
return appMetaTags(msg`Analytics`);
}
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
// `getTeamByUrl` throws when the user isn't a member; treat that like any other
// denial so the documents route renders its "Team not found" state instead of a 500.
const team = await getTeamByUrl({ userId: session.user.id, teamUrl: params.teamUrl }).catch(() => null);
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
throw redirect(formatDocumentsPath(params.teamUrl));
}
return {};
}
/**
* The timezone is read from the browser so the analytics queries only run on the
* client, after hydration, with the correct day boundaries.
*/
export async function clientLoader({ serverLoader }: Route.ClientLoaderArgs) {
await serverLoader();
return { timezone: resolveBrowserTimezone() };
}
clientLoader.hydrate = true as const;
export function HydrateFallback() {
return <AnalyticsHydrateFallback />;
}
export default function TeamAnalyticsPage({ loaderData }: Route.ComponentProps) {
const team = useCurrentTeam();
const { _ } = useLingui();
const organisation = useCurrentOrganisation();
const { timezone } = loaderData;
const { value: range, rangeKey, setValue: setRange } = useAnalyticsRange();
// `from`/`to` are only present for custom ranges.
const queryInput = { teamId: team.id, timezone, ...range };
const overviewQuery = trpc.team.analytics.getOverview.useQuery(queryInput);
const documentsOverTimeQuery = trpc.team.analytics.getDocumentsOverTime.useQuery(queryInput);
const statusBreakdownQuery = trpc.team.analytics.getStatusBreakdown.useQuery(queryInput);
const templateUsageQuery = trpc.team.analytics.getTemplateUsage.useQuery({ ...queryInput, limit: TEMPLATE_LIMIT });
const memberActivityQuery = trpc.team.analytics.getMemberActivity.useQuery(queryInput);
const hasNoActivity =
overviewQuery.isSuccess &&
documentsOverTimeQuery.isSuccess &&
overviewQuery.data.sent.current === 0 &&
overviewQuery.data.sent.previous === 0 &&
documentsOverTimeQuery.data.total === 0;
const templatesPath = formatTemplatesPath(team.url);
const memberRows: AnalyticsActivityRow[] = (memberActivityQuery.data?.members ?? []).map((member) => ({
key: member.userId,
avatar: { imageId: member.avatarImageId, fallback: formatMemberInitials(member.name, member.email) },
title: member.name || member.email,
subtitle: member.name ? member.email : null,
sent: member.sent,
completed: member.completed,
pending: member.pending,
completionRate: member.completionRate,
lastActiveAt: member.lastActiveAt,
}));
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<AnalyticsPageHeader
className="mt-8"
avatarImageId={team.avatarImageId}
name={team.name}
range={range}
onRangeChange={setRange}
actions={
canAccessOrganisationAnalytics(organisation.currentOrganisationRole) && (
<Button variant="ghost" size="sm" className="text-muted-foreground" asChild>
<Link to={formatOrganisationAnalyticsPath(organisation.url)}>
<Trans>View organisation analytics</Trans>
<ArrowRightIcon className="ml-1.5 h-3.5 w-3.5" />
</Link>
</Button>
)
}
/>
{hasNoActivity && (
<AnalyticsNoActivityAlert range={range.range} onShowLastYear={() => setRange({ range: '12m' })} />
)}
<div className="mt-6 flex flex-col gap-4">
<AnalyticsOverviewCards
query={overviewQuery}
entity={{
icon: UsersIcon,
title: <Trans>Members</Trans>,
testId: 'analytics-members',
select: (data) => data.members,
}}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<AnalyticsDocumentsOverTimeCard range={range} query={documentsOverTimeQuery} className="lg:col-span-2" />
<AnalyticsStatusBreakdownCard query={statusBreakdownQuery} />
</div>
<AnalyticsTemplateUsageCard
query={templateUsageQuery}
templatesHref={templatesPath}
getTemplateHref={(template) =>
template.envelopeId !== null ? `${templatesPath}/${template.envelopeId}` : null
}
/>
<AnalyticsActivityTableCard
query={memberActivityQuery}
rows={memberRows}
rangeKey={rangeKey}
title={<Trans>Member activity</Trans>}
description={<Trans>Documents sent by each member in this period</Trans>}
columnLabel={<Trans>Member</Trans>}
renderSummary={(count, activeCount) => (
<>
<Plural value={count} one="# member" other="# members" /> ·{' '}
<Trans>{activeCount} active this period</Trans>
</>
)}
renderShowing={(visibleCount, totalCount) => (
<Trans>
Showing {visibleCount} of {totalCount} members
</Trans>
)}
emptyLabel={<Trans>No members</Trans>}
searchPlaceholder={_(msg`Search members`)}
noSearchResultsLabel={<Trans>No members match your search</Trans>}
testIdPrefix="member"
/>
</div>
</div>
);
}
const TEMPLATE_LIMIT = 5;
const formatMemberInitials = (name: string | null, email: string) => {
const initials = name ? extractInitials(name) : '';
return initials || email.slice(0, 1).toUpperCase();
};
@@ -33,6 +33,7 @@ export default function TeamsSettingsPage() {
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
allowDocumentRejection,
aiFeaturesEnabled,
} = data;
@@ -57,6 +58,7 @@ export default function TeamsSettingsPage() {
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}),
delegateDocumentOwnership,
allowDocumentRejection,
},
});
@@ -5,28 +5,26 @@ import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, OrganisationType } from '@prisma/client';
import { Bird, XIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { Bird } from 'lucide-react';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router';
import { useParams, useSearchParams } from 'react-router';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateSearch } from '~/components/general/template/template-search';
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
import { TemplatesTable } from '~/components/tables/templates-table';
import { TemplatesTableOwnerFilter } from '~/components/tables/templates-table-owner-filter';
import { TemplatesTableViewFilter } from '~/components/tables/templates-table-view-filter';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
import { templatesSearchParams } from '~/utils/templates-search-params';
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
export function meta() {
return appMetaTags(msg`Templates`);
@@ -41,26 +39,15 @@ export default function TemplatesPage() {
const organisation = useCurrentOrganisation();
const { folderId } = useParams();
const [findTemplateSearchParams, setFindTemplateSearchParams] = useQueryStates(templatesSearchParams, {
history: 'push',
});
const page = findTemplateSearchParams.page || undefined;
const perPage = findTemplateSearchParams.perPage || undefined;
const query = findTemplateSearchParams.query || undefined;
const ownerIds = findTemplateSearchParams.ownerIds ?? undefined;
const [searchParams] = useSearchParams();
const isOrgView = findTemplateSearchParams.view === 'organisation';
const showOrgFilter = organisation.type !== OrganisationType.PERSONAL;
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const hasActiveFilters = Boolean(ownerIds?.length);
const isSearchingOrFiltering = hasActiveFilters || Boolean(query);
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
const onResetFilters = () => {
void setFindTemplateSearchParams({
ownerIds: null,
page: null,
});
};
const isOrgView = view === 'organisation';
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
@@ -77,13 +64,11 @@ export default function TemplatesPage() {
const documentRootPath = formatDocumentsPath(team.url);
const templateRootPath = formatTemplatesPath(team.url);
const teamTemplatesQuery = trpc.template.findTemplatesInternal.useQuery(
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
{
page,
perPage,
folderId,
query,
ownerIds,
},
{
enabled: !isOrgView,
@@ -94,7 +79,6 @@ export default function TemplatesPage() {
{
page,
perPage,
query,
},
{
enabled: isOrgView,
@@ -103,6 +87,14 @@ export default function TemplatesPage() {
const activeQuery = isOrgView ? orgTemplatesQuery : teamTemplatesQuery;
const handleViewChange = (newView: string) => {
if (newView !== 'team' && newView !== 'organisation') {
return;
}
void setView(newView === 'team' ? null : newView);
};
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
@@ -120,25 +112,31 @@ export default function TemplatesPage() {
</h1>
</div>
<div className="mt-6 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<TemplateSearch />
{showOrgTab && (
<div className="mt-6">
<Tabs value={view} onValueChange={handleViewChange} data-testid="template-view-tabs">
<TabsList>
<TabsTrigger
className="min-w-[60px] hover:text-foreground"
value="team"
data-testid="template-tab-team"
>
<Trans>Team</Trans>
</TabsTrigger>
<TabsTrigger
className="min-w-[60px] hover:text-foreground"
value="organisation"
data-testid="template-tab-organisation"
>
<Trans>Organisation</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{showOrgFilter && <TemplatesTableViewFilter />}
{!isOrgView && <TemplatesTableOwnerFilter teamId={team.id} />}
{hasActiveFilters && (
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
<Trans>Reset</Trans>
<XIcon className="ml-1 h-4 w-4" />
</Button>
)}
</div>
)}
<div className="mt-8">
{activeQuery.data && activeQuery.data.count === 0 && !isSearchingOrFiltering ? (
{activeQuery.data && activeQuery.data.count === 0 ? (
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<Bird className="h-12 w-12" strokeWidth={1.5} />
-193
View File
@@ -1,193 +0,0 @@
import type { TTeamAnalyticsRange } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import {
ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK,
ZAnalyticsDateSchema,
ZTeamAnalyticsRangeSchema,
} from '@documenso/trpc/server/team-router/get-team-analytics.types';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { DateTime, IANAZone, Interval } from 'luxon';
import { createParser, parseAsStringEnum, useQueryStates } from 'nuqs';
import { useEffect, useMemo } from 'react';
/**
* The subset of a tRPC query result the analytics cards need. The queries live on
* the page (team or organisation) so the cards stay scope-agnostic.
*/
export type AnalyticsQueryResult<TData> = {
data: TData | undefined;
isLoading: boolean;
isError: boolean;
refetch: () => Promise<unknown>;
};
export type TAnalyticsPresetRange = Exclude<TTeamAnalyticsRange, 'custom'>;
/**
* A fully specified analytics range: either a preset, or a custom inclusive
* [from, to] window of yyyy-MM-dd calendar dates in the browser timezone.
*/
export type AnalyticsRangeValue =
| { range: TAnalyticsPresetRange; from?: undefined; to?: undefined }
| { range: 'custom'; from: string; to: string };
export const ANALYTICS_PRESET_RANGES: TAnalyticsPresetRange[] = ZTeamAnalyticsRangeSchema.options.filter(
(range): range is TAnalyticsPresetRange => range !== 'custom',
);
export const ANALYTICS_RANGE_LABELS: Record<TTeamAnalyticsRange, MessageDescriptor> = {
'7d': msg`Last 7 days`,
'30d': msg`Last 30 days`,
'90d': msg`Last 90 days`,
'12m': msg`Last 12 months`,
custom: msg`Custom range`,
};
export const ANALYTICS_NO_ACTIVITY_LABELS: Record<TTeamAnalyticsRange, MessageDescriptor> = {
'7d': msg`No activity in the last 7 days.`,
'30d': msg`No activity in the last 30 days.`,
'90d': msg`No activity in the last 90 days.`,
'12m': msg`No activity in the last 12 months.`,
custom: msg`No activity in the selected range.`,
};
const DEFAULT_ANALYTICS_RANGE: TAnalyticsPresetRange = '30d';
/**
* The browser's IANA timezone, falling back to UTC when it cannot be resolved or
* is not a zone luxon recognises. Client-only.
*/
export const resolveBrowserTimezone = () => {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return IANAZone.isValidZone(browserTimezone) ? browserTimezone : 'UTC';
};
export const formatRelativeDate = (date: Date, locale: string) => {
return DateTime.fromJSDate(date).setLocale(locale).toRelative() ?? '';
};
/** Parse a strict yyyy-MM-dd calendar date at local midnight, null when invalid. */
export const parseAnalyticsDate = (value: string): DateTime | null => {
if (!ZAnalyticsDateSchema.safeParse(value).success) {
return null;
}
const parsed = DateTime.fromISO(value);
// Round-trip guard so overflowing dates such as 2023-02-30 are rejected.
if (!parsed.isValid || parsed.toISODate() !== value) {
return null;
}
return parsed.startOf('day');
};
/** Format a local `Date` (e.g. one picked in the calendar) as yyyy-MM-dd. */
export const formatAnalyticsDate = (date: Date): string => {
return DateTime.fromJSDate(date).toISODate() ?? '';
};
/** Human readable inclusive span, e.g. "Feb 1 – 29, 2024". */
export const formatAnalyticsDateRange = (from: string, to: string, locale: string): string => {
const start = DateTime.fromISO(from);
const end = DateTime.fromISO(to);
if (!start.isValid || !end.isValid) {
return `${from} – ${to}`;
}
return Interval.fromDateTimes(start, end).toLocaleString(DateTime.DATE_MED, { locale });
};
/** Number of calendar days in the inclusive [from, to] window, 0 when invalid. */
export const getAnalyticsDateRangeDays = (from: string, to: string): number => {
const start = parseAnalyticsDate(from);
const end = parseAnalyticsDate(to);
if (!start || !end || start > end) {
return 0;
}
return Math.round(end.diff(start, 'days').days) + 1;
};
/**
* Validate a custom window client-side, mirroring the backend resolver: both
* dates present and valid, ordered, not in the future and within the maximum span.
*/
export const isValidAnalyticsCustomRange = (from: string | null, to: string | null): boolean => {
if (!from || !to) {
return false;
}
const start = parseAnalyticsDate(from);
const end = parseAnalyticsDate(to);
if (!start || !end || start > end) {
return false;
}
const today = DateTime.local().startOf('day');
if (end > today) {
return false;
}
return start >= today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK);
};
const parseAsAnalyticsDate = createParser<string>({
parse: (value) => (parseAnalyticsDate(value) ? value : null),
serialize: (value) => value,
});
export const analyticsRangeSearchParams = {
range: parseAsStringEnum(ZTeamAnalyticsRangeSchema.options).withDefault(DEFAULT_ANALYTICS_RANGE),
from: parseAsAnalyticsDate,
to: parseAsAnalyticsDate,
};
/**
* The analytics range held in the URL (`?range=`, plus `?from=&to=` for custom
* ranges). A custom range with missing or invalid bounds falls back to the default
* preset and the stray params are cleared from the URL.
*/
export const useAnalyticsRange = () => {
const [{ range, from, to }, setSearchParams] = useQueryStates(analyticsRangeSearchParams);
const value = useMemo((): AnalyticsRangeValue => {
if (range !== 'custom') {
return { range };
}
if (from && to && isValidAnalyticsCustomRange(from, to)) {
return { range, from, to };
}
return { range: DEFAULT_ANALYTICS_RANGE };
}, [range, from, to]);
const hasInvalidCustomRange = range === 'custom' && value.range !== 'custom';
useEffect(() => {
if (hasInvalidCustomRange) {
void setSearchParams({ range: null, from: null, to: null });
}
}, [hasInvalidCustomRange, setSearchParams]);
const setValue = (next: AnalyticsRangeValue) => {
if (next.range === 'custom') {
void setSearchParams({ range: next.range, from: next.from, to: next.to });
return;
}
void setSearchParams({ range: next.range, from: null, to: null });
};
/** Changes whenever the effective window changes, including a custom span being adjusted. */
const rangeKey = `${value.range}:${value.from ?? ''}:${value.to ?? ''}`;
return { value, rangeKey, setValue };
};
@@ -1,33 +0,0 @@
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { INBOX_STATUSES, type TInboxStatus } from '@documenso/trpc/server/document-router/find-inbox.types';
import { documentsSearchParams } from './documents-search-params';
/**
* The statuses that can be selected from the inbox status filter.
*/
export const INBOX_SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [...INBOX_STATUSES];
/**
* Shared nuqs parsers for the inbox page URL state.
*
* Reuses the documents parsers so the shared filter components
* (`DocumentSearch`, `DocumentsTableStatusFilter`) read and write the same
* params on both pages.
*/
export const inboxSearchParams = {
status: documentsSearchParams.status,
page: documentsSearchParams.page,
perPage: documentsSearchParams.perPage,
query: documentsSearchParams.query,
};
/**
* Narrows the URL `status` param to a status supported by the inbox.
*
* Returns `undefined` when it is missing or not selectable, which shows every
* non-draft document.
*/
export const resolveInboxStatus = (status: ExtendedDocumentStatus | null): TInboxStatus | undefined => {
return INBOX_STATUSES.find((value) => value === status);
};
@@ -1,17 +0,0 @@
import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs';
export const TEMPLATES_VIEW_VALUES = ['team', 'organisation'] as const;
/**
* Shared nuqs parsers for the templates page URL state.
*
* Used by the templates page and its filter components so every consumer
* parses and serialises the params identically.
*/
export const templatesSearchParams = {
view: parseAsStringLiteral(TEMPLATES_VIEW_VALUES),
ownerIds: parseAsArrayOf(parseAsInteger),
page: parseAsInteger,
perPage: parseAsInteger,
query: parseAsString,
};
+38 -1
View File
@@ -1,3 +1,4 @@
import { extractSessionCookieFromHeaders } from '@documenso/auth/server/lib/session/session-cookies';
import { extractRequestMetadata, type RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Context, Next } from 'hono';
@@ -13,13 +14,49 @@ export type AppContext = {
*/
export const appContext = async (c: Context, next: Next) => {
const request = c.req.raw;
const url = new URL(request.url);
const noSessionCookie = extractSessionCookieFromHeaders(request.headers) === null;
setAppContext(c, {
requestMetadata: extractRequestMetadata(request),
});
return next();
// These are non page paths like API.
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
return await next();
}
// Add context to any pages you want here.
return await next();
};
const setAppContext = (c: Context, context: AppContext) => {
c.set('context', context);
};
const isPageRequest = (request: Request) => {
const url = new URL(request.url);
if (request.method !== 'GET') {
return false;
}
// If it ends with .data it's the loader which we need to pass context for.
if (url.pathname.endsWith('.data')) {
return true;
}
if (request.headers.get('Accept')?.includes('text/html')) {
return true;
}
return false;
};
/**
* List of paths to reject
* - Urls that start with /api
* - Urls that start with _
*/
const blacklistedPathsRegex = /^\/api\/|^\/__/;
+3682 -1505
View File
File diff suppressed because it is too large Load Diff
+2 -17
View File
@@ -46,21 +46,6 @@
"npm": ">=11.17.0",
"node": ">=24.0.0"
},
"allowScripts": {
"@documenso/skia-canvas": true,
"@playwright/browser-chromium": true,
"@prisma/client": true,
"@prisma/engines": true,
"aws-crt": true,
"esbuild": true,
"fsevents": true,
"inngest-cli": true,
"prisma": true,
"@datadog/pprof": false,
"core-js": false,
"msgpackr-extract": false,
"protobufjs": false
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@types/react": "^19.2.17",
@@ -82,7 +67,7 @@
"esbuild": "^0.28.1",
"husky": "^9.1.7",
"inngest": "^3.54.0",
"inngest-cli": "^1.44.0",
"inngest-cli": "^1.17.9",
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^9.0.0",
@@ -145,7 +130,7 @@
"posthog-node": "4.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"sharp": "0.35.4",
"sharp": "0.35.3",
"typescript": "5.6.2",
"@marsidev/react-turnstile": "^1.5.0",
"zod": "^3.25.76"
@@ -1,423 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import {
seedCancelledDocument,
seedCompletedDocument,
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, RecipientRole, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
type InboxFindInput = {
query?: string;
status?: string;
page?: number;
perPage?: number;
};
type InboxFindDocument = {
envelopeId: string;
title: string;
status: string;
recipients: Array<{ email: string; token: string }>;
};
/**
* Calls `document.inbox.find` directly, bypassing any UI level restrictions so
* we can assert the server rejects or ignores hostile input on its own.
*/
const trpcInboxFind = async (page: Page, input: InboxFindInput) => {
const inputParam = encodeURIComponent(JSON.stringify({ json: input }));
const url = `${WEBAPP_BASE_URL}/api/trpc/document.inbox.find?input=${inputParam}`;
const res = await page.context().request.get(url);
return {
res,
data: res.ok()
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
((await res.json()).result.data.json as { data: InboxFindDocument[]; count: number })
: null,
};
};
const titlesOf = (data: { data: InboxFindDocument[] } | null) => (data?.data ?? []).map((doc) => doc.title);
// ─── Recipient scoping ───────────────────────────────────────────────────────
test.describe('Inbox Find - Recipient Scoping', () => {
test('should not return documents the user is not a recipient of, even when searched by title', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: victim } = await seedUser();
const { user: attacker } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [victim], {
createDocumentOptions: { title: 'Confidential Merger Agreement' },
});
await seedCompletedDocument(sender, senderTeam.id, [victim], {
createDocumentOptions: { title: 'Confidential Severance Package' },
});
// Positive control: the actual recipient can find them.
await apiSignin({ page, email: victim.email });
const victimPending = await trpcInboxFind(page, { query: 'Confidential', status: 'PENDING' });
expect(titlesOf(victimPending.data)).toEqual(['Confidential Merger Agreement']);
const victimCompleted = await trpcInboxFind(page, { query: 'Confidential', status: 'COMPLETED' });
expect(titlesOf(victimCompleted.data)).toEqual(['Confidential Severance Package']);
await apiSignout({ page });
// The attacker knows the exact title but is not a recipient.
await apiSignin({ page, email: attacker.email });
for (const status of ['PENDING', 'COMPLETED', undefined]) {
const { res, data } = await trpcInboxFind(page, { query: 'Confidential', status });
expect(res.ok()).toBeTruthy();
expect(data?.count).toBe(0);
expect(titlesOf(data)).toEqual([]);
}
await apiSignout({ page });
});
test('should not return documents where the user is only a CC recipient', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
const ccDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'CC Only Contract' },
});
await prisma.recipient.updateMany({
where: { envelopeId: ccDocument.id, email: recipient.email },
data: { role: RecipientRole.CC },
});
// Positive control on the same account.
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Signer Contract' },
});
await apiSignin({ page, email: recipient.email });
const unfiltered = await trpcInboxFind(page, {});
expect(titlesOf(unfiltered.data)).toEqual(['Signer Contract']);
const searched = await trpcInboxFind(page, { query: 'CC Only' });
expect(titlesOf(searched.data)).toEqual([]);
await apiSignout({ page });
});
test('should not expose team documents to team members who are not recipients', async ({ page }) => {
const { team, owner } = await seedTeam();
const { user: outsideRecipient } = await seedUser();
// A team admin can see this document on the team documents page, but the
// inbox is strictly recipient scoped.
const teamAdmin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
await seedPendingDocument(owner, team.id, [outsideRecipient], {
createDocumentOptions: { title: 'Team Payroll Summary' },
});
await seedCompletedDocument(owner, team.id, [outsideRecipient], {
createDocumentOptions: { title: 'Team Board Minutes' },
});
await apiSignin({ page, email: teamAdmin.email });
const pending = await trpcInboxFind(page, { query: 'Team', status: 'PENDING' });
expect(titlesOf(pending.data)).toEqual([]);
const completed = await trpcInboxFind(page, { query: 'Team', status: 'COMPLETED' });
expect(titlesOf(completed.data)).toEqual([]);
await apiSignout({ page });
// The document owner is also not a recipient, so it should not be in their inbox either.
await apiSignin({ page, email: owner.email });
const ownerResult = await trpcInboxFind(page, { query: 'Team' });
expect(titlesOf(ownerResult.data)).toEqual([]);
await apiSignout({ page });
});
test('should mask signing tokens of other recipients', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
const { user: otherRecipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], {
createDocumentOptions: { title: 'Shared Token Document' },
});
await apiSignin({ page, email: recipient.email });
const { data } = await trpcInboxFind(page, { query: 'Shared Token', status: 'PENDING' });
expect(data?.data).toHaveLength(1);
const document = data?.data[0];
const ownRecipient = document?.recipients.find((r) => r.email === recipient.email);
const foreignRecipient = document?.recipients.find((r) => r.email === otherRecipient.email);
expect(ownRecipient?.token).toBeTruthy();
expect(foreignRecipient?.token).toBe('');
await apiSignout({ page });
});
});
// ─── Search hardening ────────────────────────────────────────────────────────
test.describe('Inbox Find - Search Hardening', () => {
test('should keep wildcard searches scoped to the recipient inbox', async ({ page }) => {
// SQL LIKE wildcards ("%" and "_") are intentionally passed through so
// users can do advanced searches. That must only ever widen the title
// match, never the recipient scoping.
const { user: sender, team: senderTeam } = await seedUser();
const { user: victim } = await seedUser();
const { user: attacker } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [victim], {
createDocumentOptions: { title: 'Victim Alpha Report' },
});
await seedCompletedDocument(sender, senderTeam.id, [victim], {
createDocumentOptions: { title: 'Victim Beta Report' },
});
// The attacker has one document of their own so we can prove wildcards
// return their inbox and nothing more.
await seedPendingDocument(sender, senderTeam.id, [attacker], {
createDocumentOptions: { title: 'Attacker Own Report' },
});
const wildcardQueries = ['%', '_', '%%%', '%Report%', 'Victim%', 'Victim _lpha%', '\\', '%victim%'];
// Positive control: wildcards work for the actual recipient.
await apiSignin({ page, email: victim.email });
const victimAll = await trpcInboxFind(page, { query: '%' });
expect(titlesOf(victimAll.data).sort()).toEqual(['Victim Alpha Report', 'Victim Beta Report']);
const victimPattern = await trpcInboxFind(page, { query: 'Victim _lpha%' });
expect(titlesOf(victimPattern.data)).toEqual(['Victim Alpha Report']);
await apiSignout({ page });
// The attacker gets exactly their own inbox for every wildcard, never the victim's.
await apiSignin({ page, email: attacker.email });
for (const query of wildcardQueries) {
const { res, data } = await trpcInboxFind(page, { query });
expect(res.ok(), `query "${query}"`).toBeTruthy();
const titles = titlesOf(data);
expect(titles, `query "${query}"`).not.toContain('Victim Alpha Report');
expect(titles, `query "${query}"`).not.toContain('Victim Beta Report');
expect(
titles.every((title) => title === 'Attacker Own Report'),
`query "${query}"`,
).toBe(true);
}
// Wildcards combined with the status filter still cannot escape the scope.
for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED']) {
const { data } = await trpcInboxFind(page, { query: '%', status });
expect(titlesOf(data), `status "${status}"`).not.toContain('Victim Alpha Report');
expect(titlesOf(data), `status "${status}"`).not.toContain('Victim Beta Report');
}
await apiSignout({ page });
});
test('should only match against the document title', async ({ page }) => {
// Explicit names so the negative queries below are deterministic.
const { user: sender, team: senderTeam } = await seedUser({ name: 'Sender Person' });
const { user: recipient } = await seedUser({ name: 'Recipient Person' });
await seedPendingDocument(sender, senderTeam.id, ['zebra-person@test.documenso.com', recipient], {
createDocumentOptions: {
title: 'Plain Title',
externalId: 'ext-hidden-identifier',
},
});
await apiSignin({ page, email: recipient.email });
// Positive control.
const byTitle = await trpcInboxFind(page, { query: 'Plain' });
expect(titlesOf(byTitle.data)).toEqual(['Plain Title']);
// External IDs, sender details and other recipients must not be probeable
// through the inbox search.
for (const query of ['ext-hidden', sender.email, 'Sender Person', 'zebra-person']) {
const { data } = await trpcInboxFind(page, { query });
expect(titlesOf(data), `query "${query}"`).toEqual([]);
}
await apiSignout({ page });
});
test('should not surface deleted, draft or template envelopes through search', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
const deletedDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Hidden Deleted Document' },
});
await prisma.envelope.update({
where: { id: deletedDocument.id },
data: { deletedAt: new Date() },
});
await seedDraftDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Hidden Draft Document' },
});
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Hidden Template Envelope', type: EnvelopeType.TEMPLATE },
});
// Positive control.
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Hidden Visible Document' },
});
await apiSignin({ page, email: recipient.email });
const unfiltered = await trpcInboxFind(page, { query: 'Hidden' });
expect(titlesOf(unfiltered.data)).toEqual(['Hidden Visible Document']);
for (const query of ['Hidden Deleted', 'Hidden Draft', 'Hidden Template']) {
const { data } = await trpcInboxFind(page, { query });
expect(titlesOf(data)).toEqual([]);
}
await apiSignout({ page });
});
});
// ─── Status filter hardening ─────────────────────────────────────────────────
test.describe('Inbox Find - Status Filter Hardening', () => {
test('should reject draft and virtual status values', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
// A recipient on a draft must never be able to pull it out via the status filter.
await seedDraftDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Unsent Draft Document' },
});
await apiSignin({ page, email: recipient.email });
for (const status of ['DRAFT', 'EXPIRED', 'INBOX', 'ALL', 'pending', 'draft', '']) {
const { res, data } = await trpcInboxFind(page, { status });
expect(res.status(), `status "${status}" should be rejected`).toBe(400);
expect(data).toBeNull();
}
// Sanity check that the valid filters, and the unfiltered view, never include the draft.
for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED', undefined]) {
const { res, data } = await trpcInboxFind(page, { status });
expect(res.ok(), `status "${status}" should be accepted`).toBeTruthy();
expect(titlesOf(data)).toEqual([]);
}
await apiSignout({ page });
});
test('should scope each status filter to exactly that status', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Scoped Pending Document' },
});
await seedCompletedDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Scoped Completed Document' },
});
await seedCancelledDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Scoped Cancelled Document' },
});
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Scoped Rejected Document', status: DocumentStatus.REJECTED },
});
await apiSignin({ page, email: recipient.email });
const expectations = [
{ status: 'PENDING', expected: ['Scoped Pending Document'] },
{ status: 'COMPLETED', expected: ['Scoped Completed Document'] },
{ status: 'CANCELLED', expected: ['Scoped Cancelled Document'] },
{ status: 'REJECTED', expected: ['Scoped Rejected Document'] },
];
for (const { status, expected } of expectations) {
const { data } = await trpcInboxFind(page, { query: 'Scoped', status });
expect(titlesOf(data), `status "${status}"`).toEqual(expected);
}
await apiSignout({ page });
});
test('should reject pagination values outside of the allowed range', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({ page, email: user.email });
const tooManyPerPage = await trpcInboxFind(page, { perPage: 101 });
expect(tooManyPerPage.res.status()).toBe(400);
const zeroPerPage = await trpcInboxFind(page, { perPage: 0 });
expect(zeroPerPage.res.status()).toBe(400);
const zeroPage = await trpcInboxFind(page, { page: 0 });
expect(zeroPage.res.status()).toBe(400);
await apiSignout({ page });
});
});
// ─── Authentication ──────────────────────────────────────────────────────────
test.describe('Inbox Find - Authentication', () => {
test('should reject unauthenticated requests', async ({ page }) => {
const { res } = await trpcInboxFind(page, { query: 'anything', status: 'PENDING' });
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
});
@@ -1,545 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { APIRequestContext, APIResponse } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const nanoid = customAlphabet('1234567890abcdef', 10);
test.describe.configure({
mode: 'parallel',
});
const TEAM_ANALYTICS_PROCEDURES = [
'team.analytics.getOverview',
'team.analytics.getDocumentsOverTime',
'team.analytics.getStatusBreakdown',
'team.analytics.getTemplateUsage',
'team.analytics.getMemberActivity',
] as const;
const ORGANISATION_ANALYTICS_PROCEDURES = [
'organisation.analytics.getOverview',
'organisation.analytics.getDocumentsOverTime',
'organisation.analytics.getStatusBreakdown',
'organisation.analytics.getTemplateUsage',
'organisation.analytics.getTeamActivity',
] as const;
const seedScenario = async () => {
const suffix = nanoid();
const { user: owner, organisation, team: siblingTeam } = await seedUser();
const targetTeamUrl = `analytics-target-${suffix}`;
await createTeam({
userId: owner.id,
teamName: `Analytics Target Team ${suffix}`,
teamUrl: targetTeamUrl,
organisationId: organisation.id,
// Keeps plain organisation members out of the target team.
inheritMembers: false,
});
const targetTeam = await prisma.team.findFirstOrThrow({ where: { url: targetTeamUrl } });
const targetManagerName = `Analytics Target Manager ${suffix}`;
const targetManager = await seedTeamMember({
teamId: targetTeam.id,
name: targetManagerName,
role: TeamMemberRole.MANAGER,
});
const targetMember = await seedTeamMember({ teamId: targetTeam.id, role: TeamMemberRole.MEMBER });
const siblingTeamAdmin = await seedTeamMember({ teamId: siblingTeam.id, role: TeamMemberRole.ADMIN });
const [organisationMember, organisationManager] = await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{ organisationRole: OrganisationMemberRole.MEMBER },
{ organisationRole: OrganisationMemberRole.MANAGER },
],
});
const { user: outsider, team: outsiderTeam, organisation: outsiderOrganisation } = await seedUser();
const { user: recipient } = await seedUser();
const template = await seedBlankTemplate(owner, targetTeam.id, {
createTemplateOptions: { title: `Analytics Target Template ${suffix}` },
});
const document = await seedBlankDocument(targetManager, targetTeam.id, {
createDocumentOptions: {
title: `Analytics Target Document ${suffix}`,
status: DocumentStatus.PENDING,
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
});
await prisma.documentAuditLog.create({
data: {
envelopeId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
createdAt: new Date(),
data: {},
},
});
await prisma.recipient.create({
data: {
envelopeId: document.id,
email: recipient.email,
name: 'Analytics Recipient',
token: nanoid(),
},
});
const markers = {
teamName: targetTeam.name,
templateTitle: template.title,
managerName: targetManagerName,
managerEmail: targetManager.email,
};
return {
owner,
organisation,
targetTeam,
targetManager,
targetMember,
siblingTeamAdmin,
organisationMember,
organisationManager,
outsider,
outsiderTeam,
outsiderOrganisation,
recipient,
markers: Object.values(markers),
namedMarkers: markers,
};
};
type Scenario = Awaited<ReturnType<typeof seedScenario>>;
type DeniedCaller = {
name: string;
caller: (scenario: Scenario) => { email: string };
};
const TEAM_DENIED_CALLERS: DeniedCaller[] = [
{ name: 'a user from another organisation', caller: (s) => s.outsider },
{ name: 'a recipient of a team document who is not a member', caller: (s) => s.recipient },
{ name: 'an organisation member who is not in the team', caller: (s) => s.organisationMember },
{ name: 'an admin of a sibling team', caller: (s) => s.siblingTeamAdmin },
{ name: 'a team member below manager', caller: (s) => s.targetMember },
];
const ORGANISATION_DENIED_CALLERS: DeniedCaller[] = [
{ name: 'a user from another organisation', caller: (s) => s.outsider },
{ name: 'a recipient of an organisation document', caller: (s) => s.recipient },
{ name: 'an organisation member', caller: (s) => s.organisationMember },
{ name: 'an organisation manager', caller: (s) => s.organisationManager },
{ name: 'a team admin who is an organisation member', caller: (s) => s.siblingTeamAdmin },
{ name: 'a team manager who is an organisation member', caller: (s) => s.targetManager },
];
test.describe('Team Analytics API - Adversarial: Access', () => {
test('should reject unauthenticated requests on every procedure', async ({ request }) => {
const { targetTeam, markers } = await seedScenario();
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(request, procedure, { teamId: targetTeam.id });
expectDenied(res, markers);
}
});
for (const { name, caller } of TEAM_DENIED_CALLERS) {
test(`should reject ${name} on every procedure`, async ({ page }) => {
const scenario = await seedScenario();
await apiSignin({ page, email: caller(scenario).email });
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, { teamId: scenario.targetTeam.id });
expectDenied(res, scenario.markers);
}
});
}
test('should allow team admins and managers (control)', async ({ page }) => {
const { owner, targetManager, organisationManager, targetTeam, namedMarkers } = await seedScenario();
for (const caller of [owner, targetManager, organisationManager]) {
await apiSignin({ page, email: caller.email });
const bodies: string[] = [];
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, { teamId: targetTeam.id });
expectAllowed(res);
bodies.push(res.body);
}
const combined = bodies.join('\n');
expect(combined).toContain(namedMarkers.templateTitle);
expect(combined).toContain(namedMarkers.managerName);
expect(combined).toContain(namedMarkers.managerEmail);
}
});
});
test.describe('Team Analytics API - Adversarial: Tampering', () => {
test('should ignore identity fields injected into the input', async ({ page }) => {
const { owner, outsider, targetTeam, markers } = await seedScenario();
await apiSignin({ page, email: outsider.email });
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, {
teamId: targetTeam.id,
userId: owner.id,
userEmail: owner.email,
});
expectDenied(res, markers);
}
});
test('should not grant access through the x-team-id header', async ({ page }) => {
const { outsider, outsiderTeam, targetTeam, markers } = await seedScenario();
await apiSignin({ page, email: outsider.email });
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const targetHeader = await trpcQuery(
page.context().request,
procedure,
{ teamId: targetTeam.id },
{ 'x-team-id': targetTeam.id.toString() },
);
expectDenied(targetHeader, markers);
const ownHeader = await trpcQuery(
page.context().request,
procedure,
{ teamId: targetTeam.id },
{ 'x-team-id': outsiderTeam.id.toString() },
);
expectDenied(ownHeader, markers);
}
});
test('should reject target team calls batched with an allowed call', async ({ page }) => {
const { outsider, outsiderTeam, targetTeam, markers } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const { response, body, results } = await trpcBatchQuery(page.context().request, [
{ procedure: 'team.analytics.getOverview', input: { teamId: outsiderTeam.id } },
...TEAM_ANALYTICS_PROCEDURES.map((procedure) => ({ procedure, input: { teamId: targetTeam.id } })),
]);
expect(response.status()).toBe(207);
expect(results).toHaveLength(TEAM_ANALYTICS_PROCEDURES.length + 1);
const [allowed, ...denied] = results;
expect(allowed.result).toBeDefined();
expect(allowed.error).toBeUndefined();
for (const item of denied) {
expect(item.result).toBeUndefined();
expect(item.error?.json.data.httpStatus).toBe(401);
expect(item.error?.json.data.code).toBe('UNAUTHORIZED');
}
expectNoMarkers(body, markers);
});
test('should reject API tokens, even one scoped to the target team', async ({ request }) => {
const { owner, targetTeam, markers } = await seedScenario();
const { token } = await createApiToken({
userId: owner.id,
teamId: targetTeam.id,
tokenName: 'analytics-adversarial',
expiresIn: null,
});
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
for (const authorization of [`Bearer ${token}`, token]) {
const res = await trpcQuery(request, procedure, { teamId: targetTeam.id }, { Authorization: authorization });
expectDenied(res, markers);
}
}
});
test('should not reveal whether a team exists', async ({ page }) => {
const { outsider, targetTeam } = await seedScenario();
await apiSignin({ page, email: outsider.email });
for (const procedure of TEAM_ANALYTICS_PROCEDURES) {
const existing = await trpcQuery(page.context().request, procedure, { teamId: targetTeam.id });
const missing = await trpcQuery(page.context().request, procedure, { teamId: 2_147_483_647 });
expect(existing.response.status()).toBe(401);
expect(missing.response.status()).toBe(401);
expect(errorMessageOf(missing)).toBe(errorMessageOf(existing));
}
});
});
test.describe('Organisation Analytics API - Adversarial: Access', () => {
test('should reject unauthenticated requests on every procedure', async ({ request }) => {
const { organisation, markers } = await seedScenario();
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(request, procedure, { organisationId: organisation.id });
expectDenied(res, markers);
}
});
for (const { name, caller } of ORGANISATION_DENIED_CALLERS) {
test(`should reject ${name} on every procedure`, async ({ page }) => {
const scenario = await seedScenario();
await apiSignin({ page, email: caller(scenario).email });
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, {
organisationId: scenario.organisation.id,
});
expectDenied(res, scenario.markers);
}
});
}
test('should allow organisation admins (control)', async ({ page }) => {
const { owner, organisation, namedMarkers } = await seedScenario();
await apiSignin({ page, email: owner.email });
const bodies: string[] = [];
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, { organisationId: organisation.id });
expectAllowed(res);
bodies.push(res.body);
}
const combined = bodies.join('\n');
expect(combined).toContain(namedMarkers.teamName);
expect(combined).toContain(namedMarkers.templateTitle);
});
});
test.describe('Organisation Analytics API - Adversarial: Tampering', () => {
test('should ignore identity fields injected into the input', async ({ page }) => {
const { owner, outsider, organisation, markers } = await seedScenario();
await apiSignin({ page, email: outsider.email });
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(page.context().request, procedure, {
organisationId: organisation.id,
userId: owner.id,
userEmail: owner.email,
});
expectDenied(res, markers);
}
});
test('should not grant access through the x-team-id header', async ({ page }) => {
const { organisationMember, organisation, targetTeam, markers } = await seedScenario();
await apiSignin({ page, email: organisationMember.email });
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const res = await trpcQuery(
page.context().request,
procedure,
{ organisationId: organisation.id },
{ 'x-team-id': targetTeam.id.toString() },
);
expectDenied(res, markers);
}
});
test('should reject target organisation calls batched with an allowed call', async ({ page }) => {
const { outsider, outsiderOrganisation, organisation, markers } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const { response, body, results } = await trpcBatchQuery(page.context().request, [
{ procedure: 'organisation.analytics.getOverview', input: { organisationId: outsiderOrganisation.id } },
...ORGANISATION_ANALYTICS_PROCEDURES.map((procedure) => ({
procedure,
input: { organisationId: organisation.id },
})),
]);
expect(response.status()).toBe(207);
expect(results).toHaveLength(ORGANISATION_ANALYTICS_PROCEDURES.length + 1);
const [allowed, ...denied] = results;
expect(allowed.result).toBeDefined();
expect(allowed.error).toBeUndefined();
for (const item of denied) {
expect(item.result).toBeUndefined();
expect(item.error?.json.data.httpStatus).toBe(401);
expect(item.error?.json.data.code).toBe('UNAUTHORIZED');
}
expectNoMarkers(body, markers);
});
test('should reject API tokens, even one belonging to an organisation admin', async ({ request }) => {
const { owner, organisation, targetTeam, markers } = await seedScenario();
const { token } = await createApiToken({
userId: owner.id,
teamId: targetTeam.id,
tokenName: 'analytics-adversarial',
expiresIn: null,
});
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
for (const authorization of [`Bearer ${token}`, token]) {
const res = await trpcQuery(
request,
procedure,
{ organisationId: organisation.id },
{ Authorization: authorization },
);
expectDenied(res, markers);
}
}
});
test('should not reveal whether an organisation exists', async ({ page }) => {
const { outsider, organisation } = await seedScenario();
await apiSignin({ page, email: outsider.email });
for (const procedure of ORGANISATION_ANALYTICS_PROCEDURES) {
const existing = await trpcQuery(page.context().request, procedure, { organisationId: organisation.id });
const missing = await trpcQuery(page.context().request, procedure, {
organisationId: `org_does_not_exist_${nanoid()}`,
});
expect(existing.response.status()).toBe(401);
expect(missing.response.status()).toBe(401);
expect(errorMessageOf(missing)).toBe(errorMessageOf(existing));
}
});
});
type TrpcResponseItem = {
result?: { data: { json: unknown } };
error?: { json: { message: string; data: { code: string; httpStatus: number } } };
};
type TrpcResult = {
response: APIResponse;
body: string;
json: TrpcResponseItem | null;
};
const DEFAULT_ANALYTICS_INPUT = { range: '30d', timezone: 'UTC' };
const trpcQuery = async (
request: APIRequestContext,
procedure: string,
input: Record<string, unknown>,
headers: Record<string, string> = {},
): Promise<TrpcResult> => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { ...DEFAULT_ANALYTICS_INPUT, ...input } }));
const response = await request.get(`${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`, { headers });
const body = await response.text();
return { response, body, json: parseJson<TrpcResponseItem>(body) };
};
const trpcBatchQuery = async (
request: APIRequestContext,
calls: Array<{ procedure: string; input: Record<string, unknown> }>,
) => {
const procedures = calls.map((call) => call.procedure).join(',');
const input = Object.fromEntries(
calls.map((call, index) => [index, { json: { ...DEFAULT_ANALYTICS_INPUT, ...call.input } }]),
);
const response = await request.get(
`${WEBAPP_BASE_URL}/api/trpc/${procedures}?batch=1&input=${encodeURIComponent(JSON.stringify(input))}`,
);
const body = await response.text();
return { response, body, results: parseJson<TrpcResponseItem[]>(body) ?? [] };
};
const parseJson = <T>(body: string): T | null => {
try {
return JSON.parse(body);
} catch {
return null;
}
};
const errorMessageOf = (res: TrpcResult) => res.json?.error?.json.message;
const expectNoMarkers = (body: string, markers: string[]) => {
for (const marker of markers) {
expect(body).not.toContain(marker);
}
};
const expectDenied = (res: TrpcResult, markers: string[]) => {
expect(res.response.status()).toBe(401);
expect(res.json?.result).toBeUndefined();
expect(res.json?.error?.json.data.code).toBe('UNAUTHORIZED');
expectNoMarkers(res.body, markers);
};
const expectAllowed = (res: TrpcResult) => {
expect(res.response.status()).toBe(200);
expect(res.json?.result?.data.json).toBeDefined();
};
@@ -167,29 +167,6 @@ test.describe('Find Documents UI - Personal Context', () => {
await expect(page.getByRole('link', { name: 'Annual Budget Plan', exact: true })).not.toBeVisible();
});
test('should reset pagination when the search query changes', async ({ page }) => {
const { user: owner, team } = await seedUser();
await seedDraftDocument(owner, team.id, [], {
createDocumentOptions: { title: 'Quarterly Report 2024' },
});
await seedDraftDocument(owner, team.id, [], {
createDocumentOptions: { title: 'Annual Budget Plan' },
});
// Start on a page that would be empty once the search narrows the results.
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/documents?page=2&perPage=1`,
});
await page.getByPlaceholder('Search documents...').fill('Quarterly');
await page.waitForURL((url) => url.searchParams.get('query') === 'Quarterly' && !url.searchParams.has('page'));
await expect(page.getByRole('link', { name: 'Quarterly Report 2024' })).toBeVisible();
});
test('should not show deleted documents', async ({ page }) => {
const { user: owner, team } = await seedUser();
@@ -1,335 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import {
seedCancelledDocument,
seedCompletedDocument,
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, RecipientRole, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({
mode: 'parallel',
});
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const DEFAULT_EMPTY_STATE = 'Documents that require your attention will appear here';
const COMPLETED_EMPTY_STATE = 'Documents that you have completed will appear here';
const SEARCH_EMPTY_STATE = 'No documents match your search';
const inboxRow = (page: Page, title: string) => page.getByRole('row').filter({ hasText: title });
const searchInbox = async (page: Page, query: string) => {
await page.getByPlaceholder('Search documents...').fill(query);
// An empty search removes the param entirely.
await page.waitForURL((url) => (url.searchParams.get('query') ?? '') === query);
};
// Rendered labels come from the compiled English catalog, which uses the US
// spelling "Canceled" for the `Cancelled` source string.
const INBOX_STATUS_LABELS = {
[DocumentStatus.PENDING]: 'Pending',
[DocumentStatus.COMPLETED]: 'Completed',
[DocumentStatus.REJECTED]: 'Rejected',
[DocumentStatus.CANCELLED]: 'Canceled',
} as const;
type InboxStatus = keyof typeof INBOX_STATUS_LABELS;
const selectInboxStatus = async (page: Page, status: InboxStatus) => {
await page.getByTestId('documents-table-status-filter').click();
await page.getByRole('option', { name: INBOX_STATUS_LABELS[status], exact: true }).click();
await page.waitForURL((url) => url.searchParams.get('status') === status);
};
// ─── Behaviour ───────────────────────────────────────────────────────────────
test.describe('Inbox - Search & Status Filter', () => {
test('should show every non-draft document by default', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Inbox Pending Document' },
});
await seedCompletedDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Inbox Completed Document' },
});
await seedCancelledDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Inbox Cancelled Document' },
});
await seedDraftDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Inbox Draft Document' },
});
await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' });
// No status selected by default.
expect(new URL(page.url()).searchParams.get('status')).toBeNull();
await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status');
await expect(inboxRow(page, 'Inbox Pending Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Draft Document')).not.toBeVisible();
await selectInboxStatus(page, DocumentStatus.COMPLETED);
await expect(page.getByTestId('documents-table-status-filter')).toContainText('Completed');
await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Pending Document')).not.toBeVisible();
await expect(inboxRow(page, 'Inbox Cancelled Document')).not.toBeVisible();
await selectInboxStatus(page, DocumentStatus.CANCELLED);
await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Pending Document')).not.toBeVisible();
await expect(inboxRow(page, 'Inbox Completed Document')).not.toBeVisible();
// Clearing the filter returns to every non-draft document.
await page.getByTestId('documents-table-status-filter').click();
await page.getByRole('option', { name: 'Clear' }).click();
await page.waitForURL((url) => url.searchParams.get('status') === null);
await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status');
await expect(inboxRow(page, 'Inbox Pending Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible();
await expect(inboxRow(page, 'Inbox Draft Document')).not.toBeVisible();
});
test('should offer every status except draft', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({ page, email: user.email, redirectPath: '/inbox' });
await page.getByTestId('documents-table-status-filter').click();
for (const visibleStatus of Object.values(INBOX_STATUS_LABELS)) {
await expect(page.getByRole('option', { name: visibleStatus, exact: true })).toBeVisible();
}
for (const hiddenStatus of ['Draft', 'Inbox', 'All', 'Expired']) {
await expect(page.getByRole('option', { name: hiddenStatus, exact: true })).not.toBeVisible();
}
});
test('should filter documents by title', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Alpha Agreement' },
});
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Beta Agreement' },
});
await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' });
await expect(inboxRow(page, 'Alpha Agreement')).toBeVisible();
await expect(inboxRow(page, 'Beta Agreement')).toBeVisible();
await searchInbox(page, 'alpha');
await expect(inboxRow(page, 'Alpha Agreement')).toBeVisible();
await expect(inboxRow(page, 'Beta Agreement')).not.toBeVisible();
await searchInbox(page, 'Gamma');
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
// Search is combined with the status filter.
await selectInboxStatus(page, DocumentStatus.COMPLETED);
await searchInbox(page, 'Agreement');
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Alpha Agreement')).not.toBeVisible();
// Clearing the search shows the status specific empty state.
await searchInbox(page, '');
await expect(page.getByText(COMPLETED_EMPTY_STATE)).toBeVisible();
});
});
// ─── Adversarial ─────────────────────────────────────────────────────────────
test.describe('Inbox - Adversarial Access', () => {
test('should not leak another user documents through search', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: victim } = await seedUser();
const { user: attacker } = await seedUser();
await seedPendingDocument(sender, senderTeam.id, [victim], {
createDocumentOptions: { title: 'Confidential Merger Agreement' },
});
await seedCompletedDocument(sender, senderTeam.id, [victim], {
createDocumentOptions: { title: 'Confidential Severance Package' },
});
// Attacker lands directly on a crafted URL with the exact title.
await apiSignin({
page,
email: attacker.email,
redirectPath: '/inbox?query=Confidential%20Merger%20Agreement',
});
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Confidential Merger Agreement')).not.toBeVisible();
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Confidential&status=COMPLETED`);
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Confidential Severance Package')).not.toBeVisible();
// Wildcards must not widen the search to everything.
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=%25`);
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(page.getByText('Confidential', { exact: false })).not.toBeVisible();
});
test('should ignore tampered status values', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
// The recipient is attached to a draft that has not been sent yet. It must
// never be reachable via the URL, regardless of the status requested.
await seedDraftDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Unsent Draft Document' },
});
await seedCancelledDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Cancelled Document' },
});
await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Legit Pending Document' },
});
// Draft, virtual and derived statuses are ignored, showing the unfiltered
// non-draft view. Kept to a handful of full page loads per test since each
// one is a fresh navigation.
const expectUnfilteredView = async () => {
await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status');
await expect(inboxRow(page, 'Legit Pending Document')).toBeVisible();
await expect(inboxRow(page, 'Cancelled Document')).toBeVisible();
await expect(inboxRow(page, 'Unsent Draft Document')).not.toBeVisible();
};
await apiSignin({ page, email: recipient.email, redirectPath: '/inbox?status=DRAFT' });
await expectUnfilteredView();
await page.goto(`${WEBAPP_BASE_URL}/inbox?status=ALL`);
await expectUnfilteredView();
await page.goto(`${WEBAPP_BASE_URL}/inbox?status=EXPIRED`);
await expectUnfilteredView();
// Searching by the exact title of the draft must not surface it either.
await searchInbox(page, 'Unsent Draft');
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
// Supported non-pending statuses are scoped to exactly that status.
await page.goto(`${WEBAPP_BASE_URL}/inbox?status=CANCELLED`);
await expect(page.getByTestId('documents-table-status-filter')).toContainText(
INBOX_STATUS_LABELS[DocumentStatus.CANCELLED],
);
await expect(inboxRow(page, 'Cancelled Document')).toBeVisible();
await expect(inboxRow(page, 'Legit Pending Document')).not.toBeVisible();
await expect(inboxRow(page, 'Unsent Draft Document')).not.toBeVisible();
});
test('should not show deleted or CC documents even when searched by exact title', async ({ page }) => {
const { user: sender, team: senderTeam } = await seedUser();
const { user: recipient } = await seedUser();
const deletedDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'Deleted Pending Document' },
});
await prisma.envelope.update({
where: { id: deletedDocument.id },
data: { deletedAt: new Date() },
});
const ccDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
createDocumentOptions: { title: 'CC Only Pending Document' },
});
await prisma.recipient.updateMany({
where: { envelopeId: ccDocument.id, email: recipient.email },
data: { role: RecipientRole.CC },
});
await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' });
await expect(page.getByText(DEFAULT_EMPTY_STATE)).toBeVisible();
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Deleted%20Pending`);
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Deleted Pending Document')).not.toBeVisible();
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=CC%20Only`);
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'CC Only Pending Document')).not.toBeVisible();
});
test('should not show team documents to team members who are not recipients', async ({ page }) => {
const { team, owner } = await seedTeam();
const { user: outsideRecipient } = await seedUser();
const teamAdmin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
await seedPendingDocument(owner, team.id, [outsideRecipient], {
createDocumentOptions: { title: 'Team Payroll Summary' },
});
await seedCompletedDocument(owner, team.id, [outsideRecipient], {
createDocumentOptions: { title: 'Team Board Minutes' },
});
// A team admin sees these on the team documents page, but the personal
// inbox is strictly recipient scoped.
await apiSignin({ page, email: teamAdmin.email, redirectPath: '/inbox?query=Team' });
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Team Payroll Summary')).not.toBeVisible();
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Team&status=COMPLETED`);
await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
await expect(inboxRow(page, 'Team Board Minutes')).not.toBeVisible();
// Positive control: the actual recipient can find both.
await page.context().clearCookies();
await apiSignin({ page, email: outsideRecipient.email, redirectPath: '/inbox?query=Team' });
await expect(inboxRow(page, 'Team Payroll Summary')).toBeVisible();
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Team&status=COMPLETED`);
await expect(inboxRow(page, 'Team Board Minutes')).toBeVisible();
});
test('should redirect unauthenticated users away from the inbox', async ({ page }) => {
await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Confidential&status=COMPLETED`);
await expect(page).toHaveURL(/\/signin/);
});
});
@@ -24,6 +24,7 @@ const TEST_SETTINGS_VALUES = {
subject: 'E2E settings subject',
message: 'E2E settings message',
language: 'French',
allowDocumentRejection: 'No',
dateFormat: 'DD/MM/YYYY',
timezone: 'Europe/London',
distributionMethod: 'None',
@@ -104,6 +105,10 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
await root.getByRole('option', { name: 'Upload' }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Allow Document Rejection').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.allowDocumentRejection, exact: true }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Date Format').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
await clickSettingsDialogHeader(root);
@@ -265,6 +270,9 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(TEST_SETTINGS_VALUES.redirectUrl);
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
await expect(getComboboxByLabel(root, 'Allow Document Rejection')).toContainText(
TEST_SETTINGS_VALUES.allowDocumentRejection,
);
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(TEST_SETTINGS_VALUES.dateFormat);
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
@@ -384,6 +392,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
expect(envelope.documentMeta.allowDocumentRejection).toBe(false);
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
const authOptions = parseAuthOptions(envelope.authOptions);
@@ -1,289 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { DocumentStatus, DocumentVisibility, OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Locator, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import { customAlphabet } from 'nanoid';
import { apiSignin, apiSignout } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const nanoid = customAlphabet('1234567890abcdef', 10);
type AnalyticsRange = '7d' | '30d' | '90d' | '12m';
/**
* Timestamps are relative to now and kept at least a day away from every window
* boundary (7, 30, 60 and 90 days) so the assertions hold regardless of timezone.
*/
const daysAgo = (days: number) => DateTime.now().minus({ days }).toJSDate();
test.describe.configure({ mode: 'parallel' });
test('[ORG ANALYTICS]: admin sees aggregated numbers across teams', async ({ page }) => {
const { owner, teamA, teamB, organisation } = await seedOrganisationWithTwoTeams();
// Team A: 4 sent, 3 completed.
await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(2) });
await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(3) });
await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(4) });
await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.PENDING, sentAt: daysAgo(5) });
// Team B: 3 sent, 2 completed. Organisation admins see every document, so the
// ADMIN-visibility one counts too.
await seedAnalyticsDocument({ owner, teamId: teamB.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(6) });
await seedAnalyticsDocument({ owner, teamId: teamB.id, status: DocumentStatus.PENDING, sentAt: daysAgo(8) });
await seedAnalyticsDocument({
owner,
teamId: teamB.id,
status: DocumentStatus.COMPLETED,
visibility: DocumentVisibility.ADMIN,
sentAt: daysAgo(9),
});
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(organisation.url) });
await waitForAnalytics(page);
// Overview: 7 sent, 5 completed (71%), both teams active.
await expect(page.getByTestId('analytics-sent')).toHaveText('7');
await expect(page.getByTestId('analytics-completion-rate')).toHaveText('71%');
await expect(page.getByTestId('analytics-teams')).toHaveText('2/2');
await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('7 total');
await expect(page.getByTestId('analytics-status-completed')).toHaveText('5');
await expect(page.getByTestId('analytics-status-pending')).toHaveText('2');
// Team activity: sorted by sent desc, so team A (4) comes before team B (3).
const rows = page.getByTestId('analytics-team-row');
await expect(page.getByTestId('analytics-team-summary')).toContainText('2 teams');
await expect(page.getByTestId('analytics-team-summary')).toContainText('2 active');
await expect(rows).toHaveCount(2);
await expect(rows.nth(0)).toContainText(teamA.name);
await expect(rows.nth(0)).toContainText(`/t/${teamA.url}`);
await expectTeamRow(rows.nth(0), { sent: '4', completed: '3', pending: '1', completionRate: '75%' });
await expect(rows.nth(1)).toContainText(teamB.name);
await expect(rows.nth(1)).toContainText(`/t/${teamB.url}`);
await expectTeamRow(rows.nth(1), { sent: '3', completed: '2', pending: '1', completionRate: '67%' });
});
test("[ORG ANALYTICS]: clicking a team row opens that team's analytics", async ({ page }) => {
const { owner, teamA, organisation } = await seedOrganisationWithTwoTeams();
// Team A has activity so it is sorted first.
await seedAnalyticsDocument({ owner, teamId: teamA.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(2) });
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(organisation.url) });
await waitForAnalytics(page);
const firstRow = page.getByTestId('analytics-team-row').first();
// The title is a real link to the team's analytics.
await expect(firstRow.getByRole('link', { name: teamA.name })).toHaveAttribute('href', `/t/${teamA.url}/analytics`);
// Clicking a non-link cell navigates via the row click handler.
await firstRow.getByTestId('analytics-team-sent').click();
await page.waitForURL(new RegExp(`/t/${teamA.url}/analytics(?:\\?.*)?$`));
});
test('[ORG ANALYTICS]: only organisation admins can access', async ({ page }) => {
const { team, owner, organisation } = await seedTeam();
// Team members seeded this way are organisation MEMBERs.
const member = await seedTeamMember({
teamId: team.id,
name: 'Analytics Member',
role: TeamMemberRole.ADMIN,
});
const [manager] = await seedOrganisationMembers({
members: [{ name: 'Analytics Manager', organisationRole: OrganisationMemberRole.MANAGER }],
organisationId: organisation.id,
});
const organisationHomePattern = new RegExp(`/o/${organisation.url}(?:\\?.*)?$`);
// Unauthenticated: the page redirects to sign in and the API rejects the call.
await page.goto(analyticsPath(organisation.url));
await page.waitForURL(/\/signin(?:\?.*)?$/);
const unauthenticatedResponse = await requestOverview(page, organisation.id);
expect(unauthenticatedResponse.status()).toBe(401);
// Organisation member: redirected to the organisation home, API rejects the call.
await apiSignin({ page, email: member.email, redirectPath: analyticsPath(organisation.url) });
await page.waitForURL(organisationHomePattern);
const memberResponse = await requestOverview(page, organisation.id);
expect(memberResponse.status()).toBe(401);
await apiSignout({ page });
// Organisation manager: the gate is ADMIN only, so managers are rejected too.
await apiSignin({ page, email: manager.email, redirectPath: analyticsPath(organisation.url) });
await page.waitForURL(organisationHomePattern);
const managerResponse = await requestOverview(page, organisation.id);
expect(managerResponse.status()).toBe(401);
await apiSignout({ page });
// Non-member denial: a user outside the organisation is redirected away and rejected by the API.
const { user: nonMember } = await seedUser();
await apiSignin({ page, email: nonMember.email, redirectPath: analyticsPath(organisation.url) });
await page.waitForURL(organisationHomePattern);
const nonMemberResponse = await requestOverview(page, organisation.id);
expect(nonMemberResponse.status()).toBe(401);
await apiSignout({ page });
// Organisation admin: the menu switcher links to the page and the API responds.
await apiSignin({ page, email: owner.email, redirectPath: `/o/${organisation.url}` });
await page.getByTestId('menu-switcher').click();
// Outside a team context the single "Analytics" item points at organisation analytics.
const analyticsMenuItem = page.getByRole('menuitem', { name: 'Analytics', exact: true });
await expect(analyticsMenuItem).toHaveAttribute('href', `/o/${organisation.url}/analytics`);
await analyticsMenuItem.click();
await page.waitForURL(new RegExp(`/o/${organisation.url}/analytics(?:\\?.*)?$`));
await waitForAnalytics(page);
// Inside a team context the item points at team analytics, and the team page links onwards.
await page.goto(`/t/${team.url}/analytics`);
await waitForAnalytics(page);
await page.getByTestId('menu-switcher').click();
await expect(page.getByRole('menuitem', { name: 'Analytics', exact: true })).toHaveAttribute(
'href',
`/t/${team.url}/analytics`,
);
await page.keyboard.press('Escape');
await page.getByRole('link', { name: 'View organisation analytics' }).click();
await page.waitForURL(new RegExp(`/o/${organisation.url}/analytics(?:\\?.*)?$`));
const adminResponse = await requestOverview(page, organisation.id);
expect(adminResponse.ok()).toBe(true);
expect(await adminResponse.text()).toContain('"completionRate"');
});
/**
* Seed an organisation with two teams. The owner is an organisation ADMIN and,
* through `inheritMembers`, an admin of both teams.
*/
const seedOrganisationWithTwoTeams = async () => {
const { owner, team: teamA, organisation } = await seedTeam();
const teamBUrl = `analytics-team-b-${nanoid()}`;
await createTeam({
userId: owner.id,
teamName: 'Analytics Team B',
teamUrl: teamBUrl,
organisationId: organisation.id,
inheritMembers: true,
});
const teamB = await prisma.team.findFirstOrThrow({
where: {
url: teamBUrl,
},
});
return { owner, teamA, teamB, organisation };
};
/**
* Seed a team document with an optional DOCUMENT_SENT audit log.
*/
const seedAnalyticsDocument = async ({
owner,
teamId,
status,
visibility = DocumentVisibility.EVERYONE,
sentAt,
}: {
owner: User;
teamId: number;
status: DocumentStatus;
visibility?: DocumentVisibility;
sentAt?: Date;
}) => {
const envelope = await seedBlankDocument(owner, teamId, {
createDocumentOptions: {
status,
visibility,
...(status === DocumentStatus.COMPLETED && sentAt ? { completedAt: sentAt } : {}),
},
});
if (sentAt) {
await prisma.documentAuditLog.createMany({
data: [
{
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
createdAt: sentAt,
data: {},
},
],
});
}
return envelope;
};
const analyticsPath = (organisationUrl: string, range?: AnalyticsRange) => {
const path = `/o/${organisationUrl}/analytics`;
return range ? `${path}?range=${range}` : path;
};
/**
* The analytics queries only run after hydration, which can be slow on a cold dev
* server, so wait for the hydrate fallback to be replaced before asserting values.
*/
const waitForAnalytics = async (page: Page) => {
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('analytics-loading')).toHaveCount(0, { timeout: 30_000 });
};
const expectTeamRow = async (
row: Locator,
expected: { sent: string; completed: string; pending: string; completionRate: string },
) => {
await expect(row.getByTestId('analytics-team-sent')).toHaveText(expected.sent);
await expect(row.getByTestId('analytics-team-completed')).toHaveText(expected.completed);
await expect(row.getByTestId('analytics-team-pending')).toHaveText(expected.pending);
await expect(row.getByTestId('analytics-team-completion-rate')).toHaveText(expected.completionRate);
};
const requestAnalytics = async (page: Page, procedure: 'getOverview' | 'getTeamActivity', organisationId: string) => {
const input = encodeURIComponent(JSON.stringify({ json: { organisationId, range: '30d', timezone: 'UTC' } }));
return await page
.context()
.request.get(`${WEBAPP_BASE_URL}/api/trpc/organisation.analytics.${procedure}?input=${input}`);
};
const requestOverview = async (page: Page, organisationId: string) => {
return await requestAnalytics(page, 'getOverview', organisationId);
};
@@ -37,6 +37,10 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Upload' }).click();
await page.keyboard.press('Escape');
// Disable document rejection
await page.getByTestId('allow-document-rejection-trigger').click();
await page.getByRole('option', { name: 'No', exact: true }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
@@ -68,10 +72,14 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(teamSettings.typedSignatureEnabled).toEqual(true);
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
expect(teamSettings.drawSignatureEnabled).toEqual(false);
expect(teamSettings.allowDocumentRejection).toEqual(false);
// Edit the team settings
await page.goto(`/t/${team.url}/settings/document`);
// Document rejection is left untouched so it keeps inheriting from the organisation.
await expect(page.getByTestId('allow-document-rejection-status')).toHaveText('Inherited');
await page.getByTestId('document-visibility-trigger').click();
await page.getByRole('option', { name: 'Everyone can access and view' }).click();
await page.getByTestId('document-language-trigger').click();
@@ -102,6 +110,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
expect(updatedTeamSettings.allowDocumentRejection).toEqual(false);
const document = await seedTeamDocumentWithMeta(team);
@@ -120,6 +129,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(documentMeta.language).toEqual('pl');
expect(documentMeta.timezone).toEqual('Europe/London');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
expect(documentMeta.allowDocumentRejection).toEqual(false);
});
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
@@ -1,690 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TGetTeamAnalyticsDocumentsOverTimeResponse } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import type { Locator, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import { apiSignin, apiSignout } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
type AnalyticsRange = '7d' | '30d' | '90d' | '12m';
/**
* Timestamps are relative to now and kept at least a day away from every window
* boundary (7, 30, 60 and 90 days) so the assertions hold regardless of timezone.
*/
const daysAgo = (days: number) => DateTime.now().minus({ days }).toJSDate();
/**
* The same day as `daysAgo` as a yyyy-MM-dd calendar date in the host timezone,
* which is also the browser timezone the page sends with custom ranges.
*/
const daysAgoDate = (days: number) => DateTime.now().minus({ days }).toFormat('yyyy-MM-dd');
test.describe.configure({ mode: 'parallel' });
test('[ANALYTICS]: admin sees overview numbers for the last 30 days', async ({ page }) => {
const { team, owner } = await seedTeam();
// Current window: 5 sent, 3 completed.
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(2) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(3) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(4) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(12) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(15) });
// Previous window: 2 sent, 1 completed.
const resentDocument = await seedAnalyticsDocument({
owner,
teamId: team.id,
status: DocumentStatus.COMPLETED,
sentAt: daysAgo(40),
});
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(45) });
// First send: a re-send inside the current window leaves the document counted once, in the previous window.
await prisma.documentAuditLog.create({
data: {
envelopeId: resentDocument.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
createdAt: daysAgo(20),
data: {},
},
});
// Soft delete: a deleted document sent in the window is excluded, so none of the numbers below change.
await seedAnalyticsDocument({
owner,
teamId: team.id,
status: DocumentStatus.COMPLETED,
sentAt: daysAgo(6),
deletedAt: new Date(),
});
// Never sent. Every document above without `createdAt` was created today.
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.DRAFT });
// Never sent, created just after local midnight so bucketing in the wrong timezone would shift their day.
const createdDaysAgo = [3, 3, 5];
for (const days of createdDaysAgo) {
await seedAnalyticsDocument({
owner,
teamId: team.id,
status: DocumentStatus.DRAFT,
createdAt: DateTime.now().minus({ days }).startOf('day').plus({ minutes: 30 }).toJSDate(),
});
}
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-range')).toContainText('Last 30 days');
await expect(page.getByTestId('analytics-sent')).toHaveText('5');
await expect(page.getByTestId('analytics-sent-delta')).toHaveText('+150%');
await expect(page.getByTestId('analytics-completion-rate')).toHaveText('60%');
await expect(page.getByTestId('analytics-completion-rate-delta')).toHaveText('+10%');
await expect(page.getByTestId('analytics-members')).toHaveText('1/1');
// Day buckets: documents land on their creation day in the request timezone and empty days are zero-filled.
const daily = await requestDocumentsOverTime(page, team.id, { range: '30d' });
expect(daily.points).toHaveLength(30);
expect(daily.points).toContainEqual({ date: daysAgoDate(3), count: 2 });
expect(daily.points).toContainEqual({ date: daysAgoDate(4), count: 0 });
expect(daily.points).toContainEqual({ date: daysAgoDate(5), count: 1 });
// Month buckets: keyed by month start, the last one sums this month's documents (8 created today, deleted excluded).
const monthStart = DateTime.now().startOf('month');
const createdThisMonth = 8 + createdDaysAgo.filter((days) => DateTime.now().minus({ days }) >= monthStart).length;
const monthly = await requestDocumentsOverTime(page, team.id, { range: '12m' });
expect(monthly.points).toHaveLength(12);
expect(monthly.points.at(-1)).toEqual({ date: monthStart.toFormat('yyyy-MM-dd'), count: createdThisMonth });
// Switching to 90 days pulls the previous window into the current one.
await selectRange(page, 'Last 90 days');
await expectRangeParam(page, '90d');
await expect(page.getByTestId('analytics-sent')).toHaveText('7');
// Loading a range directly from the URL works too. The 7 day previous window
// (7-14 days ago) only contains the document sent 12 days ago.
await page.goto(analyticsPath(team.url, '7d'));
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('3');
await expect(page.getByTestId('analytics-sent-delta')).toHaveText('+200%');
});
test('[ANALYTICS]: a custom date range scopes activity to the selected days', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(3) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(5) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(20) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(40) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(50) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(200) });
// A 16 day window loaded from the URL only contains the document sent 20 days
// ago. The equally sized previous window (41-26 days ago) contains the one sent
// 40 days ago, so the delta is flat.
const windowFrom = daysAgoDate(25);
const windowTo = daysAgoDate(10);
await apiSignin({ page, email: owner.email, redirectPath: customAnalyticsPath(team.url, windowFrom, windowTo) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('1');
await expect(page.getByTestId('analytics-sent-delta')).toHaveText('0%');
await expect(page.getByTestId('analytics-documents-over-time')).toContainText('Daily');
// The trigger shows the formatted window, e.g. "Aug 29 – Sep 13, 2026".
const rangeTrigger = page.getByTestId('analytics-range');
await expect(rangeTrigger).toContainText(String(DateTime.fromISO(windowTo).year));
// Picking a new window in the calendar replaces the current one on apply.
const pickedFrom = daysAgoDate(6);
const pickedTo = daysAgoDate(2);
await rangeTrigger.click();
await page.getByTestId('analytics-range-custom').click();
await clickCalendarDay(page, pickedFrom);
await clickCalendarDay(page, pickedTo);
await page.getByTestId('analytics-range-apply').click();
await expectCustomRangeParams(page, pickedFrom, pickedTo);
await expect(page.getByTestId('analytics-range-calendar')).toHaveCount(0);
await expect(page.getByTestId('analytics-sent')).toHaveText('2');
await expect(page.getByTestId('analytics-sent-delta')).toHaveText('New');
// Windows longer than 92 days are bucketed by month.
await page.goto(customAnalyticsPath(team.url, daysAgoDate(120), daysAgoDate(1)));
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-documents-over-time')).toContainText('Monthly');
await expect(page.getByTestId('analytics-sent')).toHaveText('5');
// An invalid window (from after to) falls back to the default preset and the
// stray params are cleared from the URL.
await page.goto(customAnalyticsPath(team.url, daysAgoDate(5), daysAgoDate(10)));
await waitForAnalytics(page);
await expect.poll(() => page.url()).not.toContain('range=custom');
await expect.poll(() => new URL(page.url()).searchParams.has('from')).toBe(false);
await expect(page.getByTestId('analytics-sent')).toHaveText('3');
// A window starting more than 12 months ago is rejected the same way.
await page.goto(customAnalyticsPath(team.url, daysAgoDate(400), daysAgoDate(380)));
await waitForAnalytics(page);
await expect.poll(() => page.url()).not.toContain('range=custom');
// The API rejects an invalid custom window outright (the resolver rules
// themselves are unit tested).
const invalidResponse = await requestOverview(page, team.id, {
range: 'custom',
from: daysAgoDate(5),
to: daysAgoDate(10),
});
expect(invalidResponse.status()).toBe(400);
});
test('[ANALYTICS]: a manager only sees documents within their visibility scope', async ({ page }) => {
const { team, owner } = await seedTeam();
const manager = await seedTeamMember({
teamId: team.id,
name: 'Analytics Manager',
role: TeamMemberRole.MANAGER,
});
for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED, DocumentStatus.PENDING]) {
await seedAnalyticsDocument({
owner,
teamId: team.id,
status,
visibility: DocumentVisibility.EVERYONE,
sentAt: daysAgo(3),
});
}
for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED]) {
await seedAnalyticsDocument({
owner,
teamId: team.id,
status,
visibility: DocumentVisibility.ADMIN,
sentAt: daysAgo(4),
});
}
// Owner clause: an ADMIN-only document the manager owns is in their scope, unlike the owner's ADMIN-only ones.
await seedAnalyticsDocument({
owner: manager,
teamId: team.id,
status: DocumentStatus.COMPLETED,
visibility: DocumentVisibility.ADMIN,
sentAt: daysAgo(3),
});
await apiSignin({ page, email: manager.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('4');
await expect(page.getByTestId('analytics-status-completed')).toHaveText('3');
await expect(page.getByTestId('analytics-status-pending')).toHaveText('1');
await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('4 total');
await expect(page.getByTestId('analytics-members')).toHaveText('2/2');
// An ADMIN-only document still counts for the manager when they are a recipient.
await seedAnalyticsDocument({
owner,
teamId: team.id,
status: DocumentStatus.PENDING,
visibility: DocumentVisibility.ADMIN,
sentAt: daysAgo(5),
recipientEmail: manager.email,
});
await page.reload();
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('5');
await expect(page.getByTestId('analytics-status-completed')).toHaveText('3');
await expect(page.getByTestId('analytics-status-pending')).toHaveText('2');
await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('5 total');
await apiSignout({ page });
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('7');
await expect(page.getByTestId('analytics-status-completed')).toHaveText('5');
await expect(page.getByTestId('analytics-status-pending')).toHaveText('2');
await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('7 total');
});
test('[ANALYTICS]: template usage ranks templates by documents created from them', async ({ page }) => {
const { team, owner } = await seedTeam();
const popularTemplate = await seedBlankTemplate(owner, team.id, {
createTemplateOptions: { title: 'Analytics Popular Template' },
});
const otherTemplate = await seedBlankTemplate(owner, team.id, {
createTemplateOptions: { title: 'Analytics Other Template' },
});
const deletedTemplate = await seedBlankTemplate(owner, team.id, {
createTemplateOptions: { title: 'Analytics Deleted Template', deletedAt: new Date() },
});
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.PENDING, sentAt: daysAgo(2) });
await seedAnalyticsDocument({ owner, teamId: team.id, status: DocumentStatus.COMPLETED, sentAt: daysAgo(3) });
// Distinct counts so the order does not depend on the tie-breaker.
for (const [template, count] of [
[popularTemplate, 3],
[otherTemplate, 2],
[deletedTemplate, 1],
] as const) {
for (let index = 0; index < count; index += 1) {
await seedAnalyticsDocument({
owner,
teamId: team.id,
status: DocumentStatus.PENDING,
sentAt: daysAgo(2),
templateSecondaryId: template.secondaryId,
});
}
}
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
const rows = page.getByTestId('analytics-template-row');
await expect(rows).toHaveCount(3);
await expect(rows.nth(0)).toContainText('Analytics Popular Template');
await expect(rows.nth(0)).toContainText('3 uses');
await expect(rows.nth(0).getByRole('link', { name: 'Analytics Popular Template' })).toHaveAttribute(
'href',
`/t/${team.url}/templates/${popularTemplate.id}`,
);
await expect(rows.nth(1)).toContainText('Analytics Other Template');
await expect(rows.nth(1)).toContainText('2 uses');
await expect(rows.nth(2)).toContainText('Unavailable template');
await expect(rows.nth(2)).toContainText('1 use');
await expect(rows.nth(2)).not.toContainText('Analytics Deleted Template');
await expect(rows.nth(2).getByRole('link')).toHaveCount(0);
});
test('[ANALYTICS]: member activity respects visibility per member', async ({ page }) => {
// `seedTeam` hardcodes the owner name, so seed the owner directly to control it.
const { user: jane, team } = await seedUser({ name: 'Jane Analytics' });
const manager = await seedTeamMember({
teamId: team.id,
name: 'Analytics Manager',
role: TeamMemberRole.MANAGER,
});
await seedTeamMember({
teamId: team.id,
name: 'Analytics Member',
role: TeamMemberRole.MEMBER,
});
// Jane: 3 EVERYONE (2 completed, 1 pending) + 2 ADMIN-only (both completed).
for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED, DocumentStatus.PENDING]) {
await seedAnalyticsDocument({
owner: jane,
teamId: team.id,
status,
visibility: DocumentVisibility.EVERYONE,
sentAt: daysAgo(3),
});
}
for (const status of [DocumentStatus.COMPLETED, DocumentStatus.COMPLETED]) {
await seedAnalyticsDocument({
owner: jane,
teamId: team.id,
status,
visibility: DocumentVisibility.ADMIN,
sentAt: daysAgo(4),
});
}
// Manager: 2 EVERYONE (1 completed, 1 pending).
for (const status of [DocumentStatus.COMPLETED, DocumentStatus.PENDING]) {
await seedAnalyticsDocument({
owner: manager,
teamId: team.id,
status,
visibility: DocumentVisibility.EVERYONE,
sentAt: daysAgo(5),
});
}
const rows = page.getByTestId('analytics-member-row');
// Admin sees everything.
await apiSignin({ page, email: jane.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-member-summary')).toContainText('3 members');
await expect(page.getByTestId('analytics-member-summary')).toContainText('2 active');
await expect(rows).toHaveCount(3);
await expect(rows.nth(0)).toContainText('Jane Analytics');
await expectMemberRow(rows.nth(0), { sent: '5', completed: '4', pending: '1', completionRate: '80%' });
await expect(rows.nth(1)).toContainText('Analytics Manager');
await expectMemberRow(rows.nth(1), { sent: '2', completed: '1', pending: '1', completionRate: '50%' });
await expect(rows.nth(2)).toContainText('Analytics Member');
await expectMemberRow(rows.nth(2), { sent: '0', completed: '0', pending: '0', completionRate: '—' });
// Search filters the table case-insensitively.
const search = page.getByTestId('analytics-member-search');
await search.fill('MANAGER');
await expect(rows).toHaveCount(1);
await expect(rows.nth(0)).toContainText('Analytics Manager');
await search.fill('');
await expect(rows).toHaveCount(3);
// Manager: Jane's two ADMIN-only documents are excluded from her row.
await apiSignout({ page });
await apiSignin({ page, email: manager.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(rows).toHaveCount(3);
await expect(rows.nth(0)).toContainText('Jane Analytics');
await expectMemberRow(rows.nth(0), { sent: '3', completed: '2', pending: '1', completionRate: '67%' });
});
test('[ANALYTICS]: member activity previews 8 members and can show all', async ({ page }) => {
// 8 organisation members inherited into the team + the owner = 9 team members.
const { team, owner } = await seedTeam({ createTeamMembers: 8 });
const rows = page.getByTestId('analytics-member-row');
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-member-summary')).toContainText('9 members');
await expect(rows).toHaveCount(8);
await page.getByTestId('analytics-member-show-all').click();
await expect(rows).toHaveCount(9);
await expect(page.getByTestId('analytics-member-show-all')).toHaveCount(0);
});
test('[ANALYTICS]: members and unauthenticated users cannot access analytics', async ({ page }) => {
const { team, owner } = await seedTeam();
const member = await seedTeamMember({
teamId: team.id,
name: 'Analytics Member',
role: TeamMemberRole.MEMBER,
});
const documentsPathPattern = new RegExp(`/t/${team.url}/documents(?:\\?.*)?$`);
// Unauthenticated: the page redirects to sign in and the API rejects the call.
await page.goto(analyticsPath(team.url));
await page.waitForURL(/\/signin(?:\?.*)?$/);
const unauthenticatedResponse = await requestOverview(page, team.id);
expect(unauthenticatedResponse.status()).toBe(401);
// Member: no nav link, redirected away from the page, API rejects the calls.
await apiSignin({ page, email: member.email, redirectPath: `/t/${team.url}/documents` });
await page.waitForURL(documentsPathPattern);
await page.getByTestId('menu-switcher').click();
// Anchor on an item every user sees, so the absence check can't pass on an unopened menu.
await expect(page.getByRole('menuitem', { name: 'Inbox', exact: true })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Analytics', exact: true })).toHaveCount(0);
await page.keyboard.press('Escape');
await page.goto(analyticsPath(team.url));
await page.waitForURL(documentsPathPattern);
const memberOverviewResponse = await requestOverview(page, team.id);
expect(memberOverviewResponse.status()).toBe(401);
const memberActivityResponse = await requestMemberActivity(page, team.id);
expect(memberActivityResponse.status()).toBe(401);
await apiSignout({ page });
// Non-member denial: a user outside the team's organisation gets "Team not found", not a crash,
// and is rejected by the API.
const { user: nonMember } = await seedUser();
await apiSignin({ page, email: nonMember.email });
await page.goto(analyticsPath(team.url));
await page.waitForURL(documentsPathPattern);
await expect(page.getByRole('heading', { name: 'Team not found' })).toBeVisible();
const nonMemberResponse = await requestOverview(page, team.id);
expect(nonMemberResponse.status()).toBe(401);
await apiSignout({ page });
// Admin: the menu switcher item leads to the analytics page.
await apiSignin({ page, email: owner.email, redirectPath: `/t/${team.url}/documents` });
await page.getByTestId('menu-switcher').click();
await page.getByRole('menuitem', { name: 'Analytics', exact: true }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/analytics(?:\\?.*)?$`));
await waitForAnalytics(page);
const adminResponse = await requestOverview(page, team.id);
expect(adminResponse.ok()).toBe(true);
});
test('[ANALYTICS]: an empty team renders empty states without errors', async ({ page }) => {
const { team, owner } = await seedTeam();
await apiSignin({ page, email: owner.email, redirectPath: analyticsPath(team.url) });
await waitForAnalytics(page);
await expect(page.getByTestId('analytics-sent')).toHaveText('0');
await expect(page.getByTestId('analytics-sent-delta')).toHaveCount(0);
await expect(page.getByTestId('analytics-completion-rate')).toHaveText('—');
await expect(page.getByTestId('analytics-completion-rate-delta')).toHaveCount(0);
await expect(page.getByTestId('analytics-members')).toHaveText('0/1');
await expect(page.getByTestId('analytics-documents-over-time-total')).toHaveText('0 total');
await expect(page.getByTestId('analytics-status-completed')).toHaveCount(0);
await expect(page.getByTestId('analytics-template-row')).toHaveCount(0);
// The zero-row checks above would also pass if a card errored, so assert that none did.
await expect(page.getByTestId('analytics-error')).toHaveCount(0);
});
/**
* Seed a team document with an optional DOCUMENT_SENT audit log, recipient and
* source template.
*/
const seedAnalyticsDocument = async ({
owner,
teamId,
status,
visibility = DocumentVisibility.EVERYONE,
sentAt,
recipientEmail,
templateSecondaryId,
createdAt,
deletedAt,
}: {
owner: User;
teamId: number;
status: DocumentStatus;
visibility?: DocumentVisibility;
sentAt?: Date;
recipientEmail?: string;
templateSecondaryId?: string;
createdAt?: Date;
deletedAt?: Date;
}) => {
const envelope = await seedBlankDocument(owner, teamId, {
createDocumentOptions: {
status,
visibility,
createdAt,
deletedAt,
...(status === DocumentStatus.COMPLETED && sentAt ? { completedAt: sentAt } : {}),
...(templateSecondaryId ? { templateId: mapSecondaryIdToTemplateId(templateSecondaryId) } : {}),
},
});
if (sentAt) {
await prisma.documentAuditLog.createMany({
data: [
{
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
createdAt: sentAt,
data: {},
},
],
});
}
if (recipientEmail) {
await prisma.recipient.create({
data: {
envelopeId: envelope.id,
email: recipientEmail,
name: 'Analytics Recipient',
token: Math.random().toString().slice(2, 12),
},
});
}
return envelope;
};
const analyticsPath = (teamUrl: string, range?: AnalyticsRange) => {
const path = `/t/${teamUrl}/analytics`;
return range ? `${path}?range=${range}` : path;
};
const customAnalyticsPath = (teamUrl: string, from: string, to: string) => {
return `${analyticsPath(teamUrl)}?range=custom&from=${from}&to=${to}`;
};
/**
* The analytics queries only run after hydration, which can be slow on a cold dev
* server, so wait for the hydrate fallback to be replaced before asserting values.
*/
const waitForAnalytics = async (page: Page) => {
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('analytics-loading')).toHaveCount(0, { timeout: 30_000 });
};
const selectRange = async (page: Page, label: string) => {
await page.getByTestId('analytics-range').click();
await page.getByRole('option', { name: label, exact: true }).click();
};
const expectRangeParam = async (page: Page, range: AnalyticsRange) => {
await expect.poll(() => new URL(page.url()).searchParams.get('range')).toBe(range);
};
const expectCustomRangeParams = async (page: Page, from: string, to: string) => {
await expect
.poll(() => {
const { searchParams } = new URL(page.url());
return { range: searchParams.get('range'), from: searchParams.get('from'), to: searchParams.get('to') };
})
.toEqual({ range: 'custom', from, to });
};
/**
* Click a yyyy-MM-dd day in the open range calendar. Each visible month renders a
* grid labelled by its caption (e.g. "September 2026"), so the day button is
* scoped to the matching grid to avoid hitting the same day number in the other month.
*/
const clickCalendarDay = async (page: Page, date: string) => {
const day = DateTime.fromISO(date).setLocale('en');
const monthGrid = page
.getByTestId('analytics-range-calendar')
.getByRole('grid', { name: day.toFormat('LLLL yyyy'), exact: true });
await monthGrid.getByRole('gridcell', { name: String(day.day), exact: true }).click();
};
const expectMemberRow = async (
row: Locator,
expected: { sent: string; completed: string; pending: string; completionRate: string },
) => {
await expect(row.getByTestId('analytics-member-sent')).toHaveText(expected.sent);
await expect(row.getByTestId('analytics-member-completed')).toHaveText(expected.completed);
await expect(row.getByTestId('analytics-member-pending')).toHaveText(expected.pending);
await expect(row.getByTestId('analytics-member-completion-rate')).toHaveText(expected.completionRate);
};
type AnalyticsRequestRange = { range: AnalyticsRange } | { range: 'custom'; from: string; to: string };
const requestAnalytics = async (
page: Page,
procedure: 'getOverview' | 'getMemberActivity' | 'getDocumentsOverTime',
teamId: number,
range: AnalyticsRequestRange = { range: '30d' },
timezone = 'UTC',
) => {
const input = encodeURIComponent(JSON.stringify({ json: { teamId, timezone, ...range } }));
return await page.context().request.get(`${WEBAPP_BASE_URL}/api/trpc/team.analytics.${procedure}?input=${input}`);
};
const requestOverview = async (page: Page, teamId: number, range?: AnalyticsRequestRange) => {
return await requestAnalytics(page, 'getOverview', teamId, range);
};
const requestMemberActivity = async (page: Page, teamId: number) => {
return await requestAnalytics(page, 'getMemberActivity', teamId);
};
/**
* Fetch documents over time in the host timezone, so bucket dates line up with
* `daysAgoDate` and the `createdAt` of documents seeded with `daysAgo`.
*/
const requestDocumentsOverTime = async (page: Page, teamId: number, range: AnalyticsRequestRange) => {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const response = await requestAnalytics(page, 'getDocumentsOverTime', teamId, range, timezone);
expect(response.ok()).toBe(true);
const body: { result: { data: { json: Pick<TGetTeamAnalyticsDocumentsOverTimeResponse, 'points'> } } } =
await response.json();
return body.result.data.json;
};
@@ -40,88 +40,6 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
test('[TEMPLATES]: search templates by title', async ({ page }) => {
const { team, owner } = await seedTeam();
await seedTemplate({
title: 'Quarterly Report Template',
userId: owner.id,
teamId: team.id,
});
await seedTemplate({
title: 'Annual Budget Template',
userId: owner.id,
teamId: team.id,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates`,
});
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
await page.getByPlaceholder('Search templates...').fill('Quarterly');
await page.waitForURL(/query=Quarterly/);
await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
await expect(page.getByRole('link', { name: 'Quarterly Report Template' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Annual Budget Template' })).not.toBeVisible();
// Clearing the search should restore the full list and drop the URL param.
await page.getByPlaceholder('Search templates...').fill('');
await page.waitForURL((url) => !url.searchParams.has('query'));
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
test('[TEMPLATES]: filter templates by owner', async ({ page }) => {
const { team, owner } = await seedTeam();
const teamMemberUser = await seedTeamMember({
teamId: team.id,
name: 'Filter Member',
role: TeamMemberRole.MEMBER,
});
await seedTemplate({
title: 'Owner Template',
userId: owner.id,
teamId: team.id,
});
await seedTemplate({
title: 'Member Template',
userId: teamMemberUser.id,
teamId: team.id,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates`,
});
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
await page.getByTestId('templates-table-owner-filter').click();
await page.getByRole('option', { name: 'Filter Member' }).click();
await page.waitForURL(/ownerIds=/);
await page.keyboard.press('Escape');
await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
await expect(page.getByRole('link', { name: 'Member Template' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Owner Template' })).not.toBeVisible();
// Reset should clear the owner filter.
await page.getByRole('button', { name: 'Reset' }).click();
await page.waitForURL((url) => !url.searchParams.has('ownerIds'));
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
test('[TEMPLATES]: delete template', async ({ page }) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
@@ -238,8 +156,8 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Get input with Email label placeholder.
await page.getByLabel('Email').click();
await page.getByLabel('Email').fill(teamMemberUser.email);
await page.getByRole('textbox', { name: 'Name', exact: true }).click();
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('name');
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
@@ -98,10 +98,10 @@ const trpcMutation = async (page: Page, procedure: string, input: Record<string,
return { res, json: res.ok() ? await res.json() : null };
};
// ─── UI: View Filter Visibility ──────────────────────────────────────────────
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
test.describe('Organisation Templates - UI View Filter', () => {
test('should show the view filter for non-personal orgs', async ({ page }) => {
test.describe('Organisation Templates - UI Tabs', () => {
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
const { ownerA, teamA } = await seedOrgTemplateScenario();
await apiSignin({
@@ -110,10 +110,11 @@ test.describe('Organisation Templates - UI View Filter', () => {
redirectPath: `/t/${teamA.url}/templates`,
});
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
await expect(page.getByTestId('template-tab-team')).toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
});
test('should not show the view filter for personal organisations', async ({ page }) => {
test('should not show tabs for personal organisations', async ({ page }) => {
const { user, team } = await seedUser({ isPersonalOrganisation: true });
await apiSignin({
@@ -122,14 +123,15 @@ test.describe('Organisation Templates - UI View Filter', () => {
redirectPath: `/t/${team.url}/templates`,
});
await expect(page.getByTestId('templates-table-view-filter')).not.toBeVisible();
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
});
});
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
test.describe('Organisation Templates - Listing', () => {
test('should list org templates from other teams under the organisation view', async ({ page }) => {
test('should list org templates from other teams under the Organisation tab', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
@@ -138,30 +140,17 @@ test.describe('Organisation Templates - Listing', () => {
redirectPath: `/t/${teamB.url}/templates`,
});
// Team view is active by default (memberB has no templates on teamB).
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
// Team tab should show 0 (memberB has no templates on teamB).
await expect(page.getByTestId('template-tab-team')).toBeVisible();
// Switch to the organisation view.
await page.getByTestId('templates-table-view-filter').click();
await page.getByRole('option', { name: 'Organization' }).click();
// Switch to Organisation tab.
await page.getByTestId('template-tab-organisation').click();
// Should see the org template from teamA.
await expect(page.getByText(orgTemplate.title)).toBeVisible();
});
test('should use default pagination when URL values are zero', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
page,
email: memberB.email,
redirectPath: `/t/${teamB.url}/templates?view=organisation&page=0&perPage=0`,
});
await expect(page.getByText(orgTemplate.title)).toBeVisible();
});
test('should not show private templates from other teams under the organisation view', async ({ page }) => {
test('should not show private templates from other teams under Organisation tab', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
// Create a private template on teamA — should NOT appear in org tab.
@@ -1,514 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { prisma } from '@documenso/prisma';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { APIResponse, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentVisibility, FolderType, TeamMemberRole, TemplateType } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { apiSignin, apiSignout } from '../fixtures/authentication';
const nanoid = customAlphabet('1234567890abcdef', 10);
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({
mode: 'parallel',
});
type FindTemplatesResult = {
data: Array<{ title: string; userId: number; teamId: number }>;
count: number;
};
type TrpcResponse = {
response: APIResponse;
result: FindTemplatesResult | null;
};
const trpcQuery = async (
page: Page,
procedure: string,
input: Record<string, unknown>,
teamId?: number,
): Promise<TrpcResponse> => {
const inputParam = encodeURIComponent(JSON.stringify({ json: { page: 1, perPage: 50, ...input } }));
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`;
const headers: Record<string, string> = teamId ? { 'x-team-id': teamId.toString() } : {};
const response = await page.context().request.get(url, { headers });
const json = response.ok() ? await response.json() : null;
const result: FindTemplatesResult | null = json ? json.result.data.json : null;
return { response, result };
};
const FIND_TEMPLATE_PROCEDURES = ['template.findTemplates', 'template.findTemplatesInternal'] as const;
const titlesOf = (res: TrpcResponse) => (res.result?.data ?? []).map((row) => row.title);
const expectRejected = (res: TrpcResponse, status: number) => {
expect(res.response.status()).toBe(status);
expect(res.result).toBeNull();
};
// Check both count and data so one cannot be wrong while the other looks fine.
const expectNoResults = (res: TrpcResponse) => {
expect(res.response.ok()).toBeTruthy();
expect(res.result?.count).toBe(0);
expect(res.result?.data).toEqual([]);
};
const expectExactTitles = (res: TrpcResponse, titles: string[], teamId: number) => {
expect(res.response.ok()).toBeTruthy();
expect(res.result?.count).toBe(titles.length);
expect(titlesOf(res).sort()).toEqual([...titles].sort());
expect(res.result?.data.every((row) => row.teamId === teamId)).toBe(true);
};
/**
* Org A has two teams. teamA is the default team, so every org member is in it.
* teamB was created with inheritMembers: false, so only people added directly are in it.
* - ownerA: org owner and teamA admin. Owns all the templates below.
* - memberA, managerA: member and manager of teamA. Not in teamB.
* - memberB: member of teamB, and also a member of teamA because teamA inherits.
* - teamA has four templates with the same suffix in the title, one per visibility:
* everyone, manager, admin (with a unique externalId and recipient email), and org.
*
* Org B is a separate org owned by "outsider". No overlap with Org A.
*
* So: memberB with a teamB header checks that teamA rows never come back.
* memberA with a teamB header checks that non-members are rejected.
*/
const seedScenario = async () => {
const { user: ownerA, organisation, team: teamA } = await seedUser();
const teamBUrl = `team-b-${nanoid()}`;
await createTeam({
userId: ownerA.id,
teamName: `Team B ${teamBUrl}`,
teamUrl: teamBUrl,
organisationId: organisation.id,
inheritMembers: false,
});
const teamB = await prisma.team.findFirstOrThrow({ where: { url: teamBUrl } });
const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER });
const managerA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MANAGER });
const memberB = await seedTeamMember({ teamId: teamB.id, role: TeamMemberRole.MEMBER });
const { user: outsider, team: outsiderTeam } = await seedUser();
const suffix = nanoid();
const hiddenRecipientEmail = `hidden-recipient-${suffix}@example.com`;
const everyoneTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Everyone Template ${suffix}`,
visibility: DocumentVisibility.EVERYONE,
},
});
const managerTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Manager Template ${suffix}`,
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
});
const adminTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Admin Only Template ${suffix}`,
externalId: `admin-external-${suffix}`,
visibility: DocumentVisibility.ADMIN,
recipients: {
create: {
email: hiddenRecipientEmail,
name: `Hidden Recipient ${suffix}`,
token: nanoid(),
},
},
},
});
const orgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Org Template ${suffix}`,
templateType: TemplateType.ORGANISATION,
visibility: DocumentVisibility.EVERYONE,
},
});
const allTitles = [everyoneTemplate.title, orgTemplate.title, managerTemplate.title, adminTemplate.title];
// Which templates each role on teamA should see.
const visibilityMatrix = [
{ caller: memberA, visible: [everyoneTemplate.title, orgTemplate.title] },
{ caller: managerA, visible: [everyoneTemplate.title, orgTemplate.title, managerTemplate.title] },
{ caller: ownerA, visible: allTitles },
];
return {
ownerA,
memberA,
managerA,
memberB,
teamA,
teamB,
outsider,
outsiderTeam,
suffix,
everyoneTemplate,
managerTemplate,
adminTemplate,
orgTemplate,
hiddenRecipientEmail,
allTitles,
visibilityMatrix,
};
};
// ─── Not logged in, or using a team header for a team you are not in ─────────
test.describe('Find Templates API - Adversarial: Auth and Team Header', () => {
for (const procedure of FIND_TEMPLATE_PROCEDURES) {
test(`${procedure}: should reject unauthenticated requests`, async ({ page }) => {
const { teamA } = await seedScenario();
const res = await trpcQuery(page, procedure, {}, teamA.id);
expectRejected(res, 401);
});
test(`${procedure}: should reject a team header for a team the user is not in`, async ({ page }) => {
const { memberA, teamA, teamB, outsider } = await seedScenario();
const adminA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN });
const cases = [
{ name: 'org member, not in team', caller: memberA, teamId: teamB.id },
{ name: 'admin of another team', caller: adminA, teamId: teamB.id },
{ name: 'other organisation', caller: outsider, teamId: teamA.id },
{ name: 'no team header', caller: memberA, teamId: undefined },
];
for (const { caller, teamId } of cases) {
await apiSignin({ page, email: caller.email });
const res = await trpcQuery(page, procedure, {}, teamId);
expectRejected(res, 404);
await apiSignout({ page });
}
});
}
test('findOrganisationTemplates: should reject a team header for a team in another org', async ({ page }) => {
const { outsider, teamA } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const res = await trpcQuery(page, 'template.findOrganisationTemplates', {}, teamA.id);
expectRejected(res, 404);
});
});
// ─── Search must not find templates you cannot see ──────────────────────────
test.describe('Find Templates API - Adversarial: Search', () => {
for (const procedure of FIND_TEMPLATE_PROCEDURES) {
test(`${procedure}: search must not find templates hidden by role`, async ({ page }) => {
const { memberA, teamA, adminTemplate, hiddenRecipientEmail } = await seedScenario();
await apiSignin({ page, email: memberA.email });
for (const query of [adminTemplate.title, adminTemplate.externalId, hiddenRecipientEmail]) {
const res = await trpcQuery(page, procedure, { query }, teamA.id);
expectNoResults(res);
}
});
test(`${procedure}: search must not find templates from another team`, async ({ page }) => {
const { memberB, teamB, everyoneTemplate } = await seedScenario();
await apiSignin({ page, email: memberB.email });
const res = await trpcQuery(page, procedure, { query: everyoneTemplate.title }, teamB.id);
expectNoResults(res);
});
test(`${procedure}: search must not find templates from another org`, async ({ page }) => {
const { outsider, outsiderTeam, everyoneTemplate } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const res = await trpcQuery(page, procedure, { query: everyoneTemplate.title }, outsiderTeam.id);
expectNoResults(res);
});
test(`${procedure}: list and search show only what each role is allowed to see`, async ({ page }) => {
const { teamA, suffix, allTitles, visibilityMatrix } = await seedScenario();
for (const { caller, visible } of visibilityMatrix) {
await apiSignin({ page, email: caller.email });
const listRes = await trpcQuery(page, procedure, {}, teamA.id);
expectExactTitles(listRes, visible, teamA.id);
const searchRes = await trpcQuery(page, procedure, { query: suffix }, teamA.id);
expectExactTitles(searchRes, visible, teamA.id);
for (const hidden of allTitles.filter((title) => !visible.includes(title))) {
const res = await trpcQuery(page, procedure, { query: hidden }, teamA.id);
expectNoResults(res);
}
await apiSignout({ page });
}
});
test(`${procedure}: wildcard search must not show more than allowed`, async ({ page }) => {
const { memberA, teamA, everyoneTemplate, orgTemplate } = await seedScenario();
await apiSignin({ page, email: memberA.email });
// % and _ are not escaped, so these match everything you are allowed to see.
for (const query of ['%', '_', '%%%']) {
const res = await trpcQuery(page, procedure, { query }, teamA.id);
expectExactTitles(res, [everyoneTemplate.title, orgTemplate.title], teamA.id);
}
for (const query of ['%Admin Only%', '%Manager%']) {
const res = await trpcQuery(page, procedure, { query }, teamA.id);
expectNoResults(res);
}
});
}
test('findOrganisationTemplates: search must not find templates from another org', async ({ page }) => {
const { outsider, outsiderTeam, orgTemplate } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const res = await trpcQuery(
page,
'template.findOrganisationTemplates',
{ query: orgTemplate.title },
outsiderTeam.id,
);
expectNoResults(res);
});
test('findOrganisationTemplates: search must not find team templates from another team', async ({ page }) => {
const { memberB, teamB, everyoneTemplate, managerTemplate, adminTemplate } = await seedScenario();
await apiSignin({ page, email: memberB.email });
for (const { title } of [everyoneTemplate, managerTemplate, adminTemplate]) {
const res = await trpcQuery(page, 'template.findOrganisationTemplates', { query: title }, teamB.id);
expectNoResults(res);
}
});
test('findOrganisationTemplates: shows only what the user role on the requesting team allows', async ({ page }) => {
const { ownerA, teamA, teamB, memberB, orgTemplate } = await seedScenario();
const managerOrgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Manager Org Template ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
});
const adminOrgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Admin Org Template ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: DocumentVisibility.ADMIN,
},
});
const managerB = await seedTeamMember({ teamId: teamB.id, role: TeamMemberRole.MANAGER });
const allOrgTitles = [orgTemplate.title, managerOrgTemplate.title, adminOrgTemplate.title];
const matrix = [
{ caller: memberB, visible: [orgTemplate.title] },
{ caller: managerB, visible: [orgTemplate.title, managerOrgTemplate.title] },
];
for (const { caller, visible } of matrix) {
await apiSignin({ page, email: caller.email });
const listRes = await trpcQuery(page, 'template.findOrganisationTemplates', {}, teamB.id);
expectExactTitles(listRes, visible, teamA.id);
for (const hidden of allOrgTitles.filter((title) => !visible.includes(title))) {
const res = await trpcQuery(page, 'template.findOrganisationTemplates', { query: hidden }, teamB.id);
expectNoResults(res);
}
await apiSignout({ page });
}
});
});
// ─── Owner filter must not reach other teams or skip visibility checks ───────
test.describe('Find Templates API - Adversarial: Owner Filter', () => {
const procedure = 'template.findTemplatesInternal';
test('owner filter must not find templates from another team', async ({ page }) => {
const { ownerA, memberB, teamB } = await seedScenario();
await apiSignin({ page, email: memberB.email });
const res = await trpcQuery(page, procedure, { ownerIds: [ownerA.id] }, teamB.id);
expectNoResults(res);
});
test('owner filter must not find templates from another org', async ({ page }) => {
const { ownerA, outsider, outsiderTeam } = await seedScenario();
await apiSignin({ page, email: outsider.email });
const res = await trpcQuery(page, procedure, { ownerIds: [ownerA.id] }, outsiderTeam.id);
expectNoResults(res);
});
test('owning a template only makes it visible in its own team', async ({ page }) => {
const { memberB, teamA, teamB } = await seedScenario();
// memberB is in both teams and owns an admin-only template in each.
// They can only see these because they own them. That must not cross teams.
const ownedOnA = await seedBlankTemplate(memberB, teamA.id, {
createTemplateOptions: { title: `Owned On A ${nanoid()}`, visibility: DocumentVisibility.ADMIN },
});
const ownedOnB = await seedBlankTemplate(memberB, teamB.id, {
createTemplateOptions: { title: `Owned On B ${nanoid()}`, visibility: DocumentVisibility.ADMIN },
});
await apiSignin({ page, email: memberB.email });
const inputs = [
{},
{ ownerIds: [memberB.id] },
{ query: 'Owned On' },
{ ownerIds: [memberB.id], query: 'Owned On' },
];
for (const input of inputs) {
// teamB has no other templates, so this is the only row.
const fromB = await trpcQuery(page, procedure, input, teamB.id);
expectExactTitles(fromB, [ownedOnB.title], teamB.id);
const fromA = await trpcQuery(page, procedure, input, teamA.id);
expect(fromA.response.ok()).toBeTruthy();
expect(titlesOf(fromA)).toContain(ownedOnA.title);
expect(titlesOf(fromA)).not.toContain(ownedOnB.title);
}
});
test('owner filter shows only what each role is allowed to see', async ({ page }) => {
const { ownerA, teamA, suffix, allTitles, visibilityMatrix } = await seedScenario();
const ownerIds = [ownerA.id];
for (const { caller, visible } of visibilityMatrix) {
await apiSignin({ page, email: caller.email });
const listRes = await trpcQuery(page, procedure, { ownerIds }, teamA.id);
expectExactTitles(listRes, visible, teamA.id);
const searchRes = await trpcQuery(page, procedure, { ownerIds, query: suffix }, teamA.id);
expectExactTitles(searchRes, visible, teamA.id);
for (const hidden of allTitles.filter((title) => !visible.includes(title))) {
const res = await trpcQuery(page, procedure, { ownerIds, query: hidden }, teamA.id);
expectNoResults(res);
}
await apiSignout({ page });
}
});
test('owner filter with unknown user ids returns nothing', async ({ page }) => {
const { ownerA, teamA } = await seedScenario();
await apiSignin({ page, email: ownerA.email });
const res = await trpcQuery(page, procedure, { ownerIds: [-1, 999999999] }, teamA.id);
expectNoResults(res);
});
test('owner filter is ignored by the public findTemplates route', async ({ page }) => {
const { ownerA, memberA, teamA, everyoneTemplate, orgTemplate } = await seedScenario();
await apiSignin({ page, email: memberA.email });
// The public route does not accept ownerIds. It should be dropped, not error.
const res = await trpcQuery(page, 'template.findTemplates', { ownerIds: [ownerA.id] }, teamA.id);
expectExactTitles(res, [everyoneTemplate.title, orgTemplate.title], teamA.id);
});
});
// ─── Folder filter must not reach other teams ────────────────────────────────
test.describe('Find Templates API - Adversarial: Folder Filter', () => {
for (const procedure of FIND_TEMPLATE_PROCEDURES) {
test(`${procedure}: folder filter must not find folders from another team`, async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedScenario();
const folderA = await seedBlankFolder(ownerA, teamA.id, {
createFolderOptions: { type: FolderType.TEMPLATE },
});
await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Foldered Template ${nanoid()}`,
visibility: DocumentVisibility.EVERYONE,
folderId: folderA.id,
},
});
await apiSignin({ page, email: memberB.email });
const res = await trpcQuery(page, procedure, { folderId: folderA.id }, teamB.id);
expectNoResults(res);
});
}
});
-27
View File
@@ -1,27 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { NEXT_PRIVATE_SIGNING_REASON } from './app';
describe('NEXT_PRIVATE_SIGNING_REASON', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('defaults to the Documenso signing reason', () => {
vi.stubEnv('NEXT_PRIVATE_SIGNING_REASON', undefined);
expect(NEXT_PRIVATE_SIGNING_REASON()).toBe('Signed by Documenso');
});
it('uses the default for an empty signing reason', () => {
vi.stubEnv('NEXT_PRIVATE_SIGNING_REASON', '');
expect(NEXT_PRIVATE_SIGNING_REASON()).toBe('Signed by Documenso');
});
it('uses the configured signing reason verbatim', () => {
vi.stubEnv('NEXT_PRIVATE_SIGNING_REASON', 'Signed by Objective');
expect(NEXT_PRIVATE_SIGNING_REASON()).toBe('Signed by Objective');
});
});
-1
View File
@@ -92,7 +92,6 @@ export const IS_AI_FEATURES_CONFIGURED = (): boolean => {
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
export const NEXT_PRIVATE_SIGNING_REASON = () => env('NEXT_PRIVATE_SIGNING_REASON') || 'Signed by Documenso';
export const NEXT_PRIVATE_SIGNING_TRANSPORT = () => env('NEXT_PRIVATE_SIGNING_TRANSPORT') || 'local';
+40 -1
View File
@@ -1,5 +1,44 @@
import { timeZonesNames } from '@vvo/tzdb';
import { rawTimeZones, timeZonesNames } from '@vvo/tzdb';
export const TIME_ZONE_DATA = rawTimeZones;
export const DEFAULT_DOCUMENT_TIME_ZONE = 'Etc/UTC';
export type TimeZone = {
name: string;
rawOffsetInMinutes: number;
};
export const minutesToHours = (minutes: number): string => {
const hours = Math.abs(Math.floor(minutes / 60));
const min = Math.abs(minutes % 60);
const sign = minutes >= 0 ? '+' : '-';
return `${sign}${String(hours).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
};
const getGMTOffsets = (timezones: TimeZone[]): string[] => {
const gmtOffsets: string[] = [];
for (const timezone of timezones) {
const offsetValue = minutesToHours(timezone.rawOffsetInMinutes);
const gmtText = `(${offsetValue})`;
gmtOffsets.push(`${timezone.name} ${gmtText}`);
}
return gmtOffsets;
};
export const splitTimeZone = (input: string | null): string => {
if (input === null) {
return '';
}
const [timeZone] = input.split('(');
return timeZone.trim();
};
export const TIME_ZONES_FULL = getGMTOffsets(TIME_ZONE_DATA);
export const TIME_ZONES = ['Etc/UTC', ...timeZonesNames];
+2 -2
View File
@@ -46,7 +46,7 @@
"ai": "^7.0.58",
"bullmq": "^5.71.1",
"colord": "^2.9.3",
"csv-parse": "^7.0.2",
"csv-parse": "^6.1.0",
"inngest": "^3.54.0",
"ioredis": "^5.10.1",
"jose": "^6.1.2",
@@ -67,7 +67,7 @@
"posthog-node": "4.18.0",
"react": "^19.2.7",
"remeda": "^2.32.0",
"sharp": "0.35.4",
"sharp": "0.35.3",
"stripe": "^12.18.0",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
+6 -4
View File
@@ -1,10 +1,12 @@
import { z } from 'zod';
import { isHttpUrl } from '../utils/is-http-url';
import { isValidRedirectUrl } from '../utils/is-valid-redirect-url';
/**
* Note this allows empty strings.
*/
export const ZUrlSchema = z.string().refine((value) => value === undefined || value === '' || isHttpUrl(value), {
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
});
export const ZUrlSchema = z
.string()
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
});
@@ -33,7 +33,11 @@ export async function rejectDocumentWithToken({ token, id, reason, requestMetada
envelope: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
},
include: {
envelope: true,
envelope: {
include: {
documentMeta: true,
},
},
},
});
@@ -51,6 +55,14 @@ export async function rejectDocumentWithToken({ token, id, reason, requestMetada
});
}
// Hiding the reject button on the signing page is not enough because the
// recipient can call this endpoint directly, so enforce the setting here.
if (!envelope.documentMeta.allowDocumentRejection) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document ${envelope.id} does not allow rejection`,
});
}
assertRecipientNotExpired(recipient);
// Update the recipient status to rejected
@@ -169,7 +169,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
const {
branding,
emailLanguage,
settings,
organisationType,
senderEmail,
replyToEmail,
@@ -226,16 +225,11 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`);
if (!emailMessage) {
const inviterName = user.name || user.email;
emailMessage = i18n._(
settings.includeSenderDetails
? msg`${inviterName} on behalf of "${envelope.team.name}" has invited you to ${recipientActionVerb} the document "${envelope.title}".`
: msg`${envelope.team.name} has invited you to ${recipientActionVerb} the document "${envelope.title}".`,
emailMessage =
envelope.documentMeta.message ||
i18n._(
msg`${user.name || user.email} on behalf of "${envelope.team.name}" has invited you to ${recipientActionVerb} the document "${envelope.title}".`,
);
}
}
const customEmailTemplate = {
@@ -262,7 +256,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
selfSigner,
organisationType,
teamName: envelope.team?.name,
includeSenderDetails: settings.includeSenderDetails,
reportUrl,
});
@@ -48,6 +48,7 @@ export const ZEnvelopeForSigningResponse = z.object({
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
allowDocumentRejection: true,
language: true,
}),
recipients: ZRecipientLiteSchema.pick({
@@ -1,81 +0,0 @@
import { kyselyPrisma, sql } from '@documenso/prisma';
import type {
TGetOrganisationAnalyticsDocumentsOverTimeRequest,
TGetOrganisationAnalyticsDocumentsOverTimeResponse,
} from '@documenso/trpc/server/organisation-router/get-organisation-analytics.types';
import { DateTime } from 'luxon';
import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range';
import { toTeamAnalyticsCount } from '../team/get-team-analytics-scope';
import { getOrganisationAnalyticsScope } from './get-organisation-analytics-scope';
export type GetOrganisationAnalyticsDocumentsOverTimeOptions = TGetOrganisationAnalyticsDocumentsOverTimeRequest & {
userId: number;
};
/**
* Number of documents created across the organisation per day (or per month for
* `12m` and long custom ranges) inside the requested window, zero-filled so every
* bucket is present. With month buckets the first and last points may cover only
* part of a month.
*/
export const getOrganisationAnalyticsDocumentsOverTime = async ({
userId,
organisationId,
range,
from,
to,
timezone,
}: GetOrganisationAnalyticsDocumentsOverTimeOptions): Promise<TGetOrganisationAnalyticsDocumentsOverTimeResponse> => {
const scope = await getOrganisationAnalyticsScope({ organisationId, userId });
const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone });
const { start, end, bucket } = resolvedRange;
// `createdAt` is a naive TIMESTAMP holding UTC, so it must be tagged as UTC before
// shifting into the request timezone; otherwise Postgres treats it as local time.
const bucketDate = sql<string>`to_char(
date_trunc(${bucket}, (${sql.ref('Envelope.createdAt')} at time zone 'UTC') at time zone ${timezone}),
'YYYY-MM-DD'
)`;
const rows = await kyselyPrisma.$kysely
.selectFrom('Envelope')
.select(({ fn }) => [bucketDate.as('date'), fn.countAll().as('count')])
.where((eb) => scope.applyEnvelopeScope(eb))
.where('Envelope.createdAt', '>=', start)
.where('Envelope.createdAt', '<', end)
.groupBy('date')
.orderBy('date')
.execute();
const countsByDate = new Map(rows.map((row) => [row.date, toTeamAnalyticsCount(row.count)]));
const points: TGetOrganisationAnalyticsDocumentsOverTimeResponse['points'] = [];
const endTime = DateTime.fromJSDate(end, { zone: timezone });
// Align the cursor to the bucket boundary so it produces the same keys as
// `date_trunc` above. A custom range starting mid-month with month buckets would
// otherwise miss its first (partial) month entirely.
let cursor = DateTime.fromJSDate(start, { zone: timezone }).startOf(bucket);
while (cursor < endTime) {
const date = cursor.toFormat('yyyy-MM-dd');
points.push({
date,
count: countsByDate.get(date) ?? 0,
});
cursor = cursor.plus(bucket === 'month' ? { months: 1 } : { days: 1 });
}
const total = points.reduce((sum, point) => sum + point.count, 0);
return {
range: resolvedRange,
total,
points,
};
};
@@ -1,117 +0,0 @@
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
import type {
TGetOrganisationAnalyticsOverviewRequest,
TGetOrganisationAnalyticsOverviewResponse,
} from '@documenso/trpc/server/organisation-router/get-organisation-analytics.types';
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range';
import { calculateTeamAnalyticsCompletionRate, toTeamAnalyticsCount } from '../team/get-team-analytics-scope';
import { getOrganisationAnalyticsScope } from './get-organisation-analytics-scope';
export type GetOrganisationAnalyticsOverviewOptions = TGetOrganisationAnalyticsOverviewRequest & {
userId: number;
};
/**
* Headline organisation analytics: documents sent, completion rate and team
* activity for the requested window and the window immediately before it.
*
* A document counts as "sent" in a window when its first DOCUMENT_SENT audit log
* falls inside that window.
*/
export const getOrganisationAnalyticsOverview = async ({
userId,
organisationId,
range,
from,
to,
timezone,
}: GetOrganisationAnalyticsOverviewOptions): Promise<TGetOrganisationAnalyticsOverviewResponse> => {
const scope = await getOrganisationAnalyticsScope({ organisationId, userId });
const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone });
const { start, end, previousStart, previousEnd } = resolvedRange;
const teamCount = await prisma.team.count({
where: {
organisationId: scope.organisationId,
},
});
const row = await kyselyPrisma.$kysely
.with('scopedEnvelopes', (db) =>
db
.selectFrom('Envelope')
.select(['Envelope.id', 'Envelope.status', 'Envelope.teamId'])
.where((eb) => scope.applyEnvelopeScope(eb)),
)
.with('firstSent', (db) =>
db
.selectFrom('DocumentAuditLog')
.innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'DocumentAuditLog.envelopeId')
.select(({ fn }) => ['DocumentAuditLog.envelopeId', fn.min('DocumentAuditLog.createdAt').as('sentAt')])
.where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT)
.where('DocumentAuditLog.createdAt', '>=', previousStart)
.where('DocumentAuditLog.createdAt', '<', end)
.groupBy('DocumentAuditLog.envelopeId'),
)
.selectFrom('firstSent')
.innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'firstSent.envelopeId')
.select(({ fn, eb }) => {
const inCurrentWindow = eb.and([eb('firstSent.sentAt', '>=', start), eb('firstSent.sentAt', '<', end)]);
const inPreviousWindow = eb.and([
eb('firstSent.sentAt', '>=', previousStart),
eb('firstSent.sentAt', '<', previousEnd),
]);
const isCompleted = eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.COMPLETED));
return [
fn.countAll().filterWhere(inCurrentWindow).as('sentCurrent'),
fn.countAll().filterWhere(inPreviousWindow).as('sentPrevious'),
fn
.countAll()
.filterWhere(eb.and([inCurrentWindow, isCompleted]))
.as('completedCurrent'),
fn
.countAll()
.filterWhere(eb.and([inPreviousWindow, isCompleted]))
.as('completedPrevious'),
fn.count('scopedEnvelopes.teamId').distinct().filterWhere(inCurrentWindow).as('activeTeams'),
];
})
.executeTakeFirst();
if (!row) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Analytics overview query returned no result',
});
}
const sentCurrent = toTeamAnalyticsCount(row.sentCurrent);
const sentPrevious = toTeamAnalyticsCount(row.sentPrevious);
const completedCurrent = toTeamAnalyticsCount(row.completedCurrent);
const completedPrevious = toTeamAnalyticsCount(row.completedPrevious);
return {
range: resolvedRange,
sent: {
current: sentCurrent,
previous: sentPrevious,
},
completionRate: {
completed: completedCurrent,
sent: sentCurrent,
rate: calculateTeamAnalyticsCompletionRate({ completed: completedCurrent, sent: sentCurrent }),
previousRate: calculateTeamAnalyticsCompletionRate({ completed: completedPrevious, sent: sentPrevious }),
},
teams: {
total: teamCount,
active: toTeamAnalyticsCount(row.activeTeams),
},
};
};
@@ -1,101 +0,0 @@
import { prisma, sql } from '@documenso/prisma';
import type { Prisma } from '@prisma/client';
import { EnvelopeType, OrganisationMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import type { ApplyEnvelopeScope } from '../team/get-team-analytics-scope';
export type GetOrganisationAnalyticsScopeOptions = {
organisationId: string;
userId: number;
};
/**
* Authorise the caller for organisation analytics (organisation ADMIN only) and
* build the envelope scope used by every organisation analytics procedure.
*
* The organisation ADMIN role authorises organisation-wide visibility. The internal
* ADMIN group is attached to every team by `createTeam` and cannot be detached, so
* every non-deleted document across the organisation's teams is in scope and no
* per-document visibility filtering is applied.
*/
export const getOrganisationAnalyticsScope = async ({
organisationId,
userId,
}: GetOrganisationAnalyticsScopeOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: [OrganisationMemberRole.ADMIN],
}),
select: {
id: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to view analytics for this organisation',
});
}
const envelopeWhere: Prisma.EnvelopeWhereInput = {
team: {
organisationId: organisation.id,
},
type: EnvelopeType.DOCUMENT,
deletedAt: null,
};
/**
* Kysely predicate equivalent of `envelopeWhere`, for use in `Envelope` queries
* that need aggregates Prisma cannot express.
*/
const applyEnvelopeScope: ApplyEnvelopeScope = (eb) =>
eb.and([
eb('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
eb('Envelope.deletedAt', 'is', null),
eb(
'Envelope.teamId',
'in',
eb.selectFrom('Team').select('Team.id').where('Team.organisationId', '=', organisation.id),
),
]);
return {
organisationId: organisation.id,
userId,
envelopeWhere,
applyEnvelopeScope,
};
};
export type OrganisationAnalyticsScope = Awaited<ReturnType<typeof getOrganisationAnalyticsScope>>;
export type OrganisationAnalyticsTeam = {
id: number;
name: string;
url: string;
avatarImageId: string | null;
};
/**
* Every team in the organisation, sorted by name.
*/
export const getOrganisationAnalyticsTeams = async (organisationId: string): Promise<OrganisationAnalyticsTeam[]> => {
const teams = await prisma.team.findMany({
where: {
organisationId,
},
select: {
id: true,
name: true,
url: true,
avatarImageId: true,
},
});
return teams.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
};
@@ -1,69 +0,0 @@
import { prisma } from '@documenso/prisma';
import type {
TGetOrganisationAnalyticsStatusBreakdownRequest,
TGetOrganisationAnalyticsStatusBreakdownResponse,
} from '@documenso/trpc/server/organisation-router/get-organisation-analytics.types';
import { DocumentStatus } from '@prisma/client';
import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range';
import { getOrganisationAnalyticsScope } from './get-organisation-analytics-scope';
export type GetOrganisationAnalyticsStatusBreakdownOptions = TGetOrganisationAnalyticsStatusBreakdownRequest & {
userId: number;
};
/**
* Current status of every document created across the organisation inside the
* requested window.
*/
export const getOrganisationAnalyticsStatusBreakdown = async ({
userId,
organisationId,
range,
from,
to,
timezone,
}: GetOrganisationAnalyticsStatusBreakdownOptions): Promise<TGetOrganisationAnalyticsStatusBreakdownResponse> => {
const scope = await getOrganisationAnalyticsScope({ organisationId, userId });
const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone });
const { start, end } = resolvedRange;
const groups = await prisma.envelope.groupBy({
by: ['status'],
where: {
...scope.envelopeWhere,
createdAt: {
gte: start,
lt: end,
},
},
_count: {
_all: true,
},
});
const counts: Record<DocumentStatus, number> = {
[DocumentStatus.DRAFT]: 0,
[DocumentStatus.PENDING]: 0,
[DocumentStatus.COMPLETED]: 0,
[DocumentStatus.REJECTED]: 0,
[DocumentStatus.CANCELLED]: 0,
};
for (const group of groups) {
counts[group.status] = group._count._all;
}
const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
return {
range: resolvedRange,
total,
draft: counts[DocumentStatus.DRAFT],
pending: counts[DocumentStatus.PENDING],
completed: counts[DocumentStatus.COMPLETED],
rejected: counts[DocumentStatus.REJECTED],
cancelled: counts[DocumentStatus.CANCELLED],
};
};
@@ -1,117 +0,0 @@
import { kyselyPrisma, sql } from '@documenso/prisma';
import type {
TGetOrganisationAnalyticsTeamActivityRequest,
TGetOrganisationAnalyticsTeamActivityResponse,
} from '@documenso/trpc/server/organisation-router/get-organisation-analytics.types';
import { DocumentStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range';
import { calculateTeamAnalyticsCompletionRate, toTeamAnalyticsCount } from '../team/get-team-analytics-scope';
import { getOrganisationAnalyticsScope, getOrganisationAnalyticsTeams } from './get-organisation-analytics-scope';
export type GetOrganisationAnalyticsTeamActivityOptions = TGetOrganisationAnalyticsTeamActivityRequest & {
userId: number;
};
/**
* Per-team activity for every team in the organisation. Teams with no activity
* are included with zeros.
*
* A document counts as "sent" by a team when it belongs to the team and has a
* DOCUMENT_SENT audit log inside the window. The app logs DOCUMENT_SENT once per
* document, so the earliest log within the window is used as its sent time (and
* drives "last active").
*/
export const getOrganisationAnalyticsTeamActivity = async ({
userId,
organisationId,
range,
from,
to,
timezone,
}: GetOrganisationAnalyticsTeamActivityOptions): Promise<TGetOrganisationAnalyticsTeamActivityResponse> => {
const scope = await getOrganisationAnalyticsScope({ organisationId, userId });
const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone });
const { start, end } = resolvedRange;
const teams = await getOrganisationAnalyticsTeams(scope.organisationId);
if (teams.length === 0) {
return {
range: resolvedRange,
teams: [],
};
}
const rows = await kyselyPrisma.$kysely
.with('scopedEnvelopes', (db) =>
db
.selectFrom('Envelope')
.select(['Envelope.id', 'Envelope.status', 'Envelope.teamId'])
.where((eb) => scope.applyEnvelopeScope(eb)),
)
.with('firstSent', (db) =>
db
.selectFrom('DocumentAuditLog')
.innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'DocumentAuditLog.envelopeId')
.select(({ fn }) => ['DocumentAuditLog.envelopeId', fn.min('DocumentAuditLog.createdAt').as('sentAt')])
.where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT)
.where('DocumentAuditLog.createdAt', '>=', start)
.where('DocumentAuditLog.createdAt', '<', end)
.groupBy('DocumentAuditLog.envelopeId'),
)
.selectFrom('firstSent')
.innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'firstSent.envelopeId')
.select(({ fn, eb }) => [
'scopedEnvelopes.teamId',
fn.countAll().as('sent'),
fn
.countAll()
.filterWhere(eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.COMPLETED)))
.as('completed'),
fn
.countAll()
.filterWhere(eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.PENDING)))
.as('pending'),
fn.max('firstSent.sentAt').as('lastActiveAt'),
])
.groupBy('scopedEnvelopes.teamId')
.execute();
const activityByTeamId = new Map(rows.map((row) => [row.teamId, row]));
const teamActivity = teams.map((team) => {
const activity = activityByTeamId.get(team.id);
const sent = activity ? toTeamAnalyticsCount(activity.sent) : 0;
const completed = activity ? toTeamAnalyticsCount(activity.completed) : 0;
const pending = activity ? toTeamAnalyticsCount(activity.pending) : 0;
return {
id: team.id,
name: team.name,
url: team.url,
avatarImageId: team.avatarImageId,
sent,
completed,
pending,
completionRate: calculateTeamAnalyticsCompletionRate({ completed, sent }),
lastActiveAt: activity?.lastActiveAt ?? null,
};
});
teamActivity.sort((a, b) => {
if (a.sent !== b.sent) {
return b.sent - a.sent;
}
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
return {
range: resolvedRange,
teams: teamActivity,
};
};
@@ -1,127 +0,0 @@
import { prisma } from '@documenso/prisma';
import type {
TGetOrganisationAnalyticsTemplateUsageRequest,
TGetOrganisationAnalyticsTemplateUsageResponse,
} from '@documenso/trpc/server/organisation-router/get-organisation-analytics.types';
import { EnvelopeType } from '@prisma/client';
import { mapTemplateIdToSecondaryId } from '../../utils/envelope';
import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range';
import { getOrganisationAnalyticsScope } from './get-organisation-analytics-scope';
export type GetOrganisationAnalyticsTemplateUsageOptions = TGetOrganisationAnalyticsTemplateUsageRequest & {
userId: number;
};
/**
* Templates across the organisation ranked by how many documents were created from
* them inside the requested window. Template metadata (including the owning team)
* is null when the template has been deleted.
*/
export const getOrganisationAnalyticsTemplateUsage = async ({
userId,
organisationId,
range,
from,
to,
timezone,
limit,
}: GetOrganisationAnalyticsTemplateUsageOptions): Promise<TGetOrganisationAnalyticsTemplateUsageResponse> => {
const scope = await getOrganisationAnalyticsScope({ organisationId, userId });
const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone });
const { start, end } = resolvedRange;
const groups = await prisma.envelope.groupBy({
by: ['templateId'],
where: {
...scope.envelopeWhere,
templateId: {
not: null,
},
createdAt: {
gte: start,
lt: end,
},
},
_count: {
_all: true,
},
orderBy: [
{
_count: {
templateId: 'desc',
},
},
{
templateId: 'asc',
},
],
take: limit,
});
const usage = groups.flatMap((group) => {
if (group.templateId === null) {
return [];
}
return [
{
templateId: group.templateId,
count: group._count._all,
},
];
});
if (usage.length === 0) {
return {
range: resolvedRange,
templates: [],
};
}
const templates = await prisma.envelope.findMany({
where: {
type: EnvelopeType.TEMPLATE,
deletedAt: null,
team: {
organisationId: scope.organisationId,
},
secondaryId: {
in: usage.map(({ templateId }) => mapTemplateIdToSecondaryId(templateId)),
},
},
select: {
id: true,
secondaryId: true,
title: true,
updatedAt: true,
team: {
select: {
id: true,
name: true,
url: true,
avatarImageId: true,
},
},
},
});
const templatesBySecondaryId = new Map(templates.map((template) => [template.secondaryId, template]));
return {
range: resolvedRange,
templates: usage.map(({ templateId, count }) => {
const template = templatesBySecondaryId.get(mapTemplateIdToSecondaryId(templateId));
return {
id: templateId,
envelopeId: template?.id ?? null,
title: template?.title ?? null,
updatedAt: template?.updatedAt ?? null,
team: template?.team ?? null,
count,
};
}),
};
};
@@ -1,81 +0,0 @@
import { kyselyPrisma, sql } from '@documenso/prisma';
import type {
TGetTeamAnalyticsDocumentsOverTimeRequest,
TGetTeamAnalyticsDocumentsOverTimeResponse,
} from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { DateTime } from 'luxon';
import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range';
import { getTeamAnalyticsScope, toTeamAnalyticsCount } from './get-team-analytics-scope';
export type GetTeamAnalyticsDocumentsOverTimeOptions = TGetTeamAnalyticsDocumentsOverTimeRequest & {
userId: number;
userEmail: string;
};
/**
* Number of visible documents created per day (or per month for `12m` and long custom
* ranges) inside the requested window, zero-filled so every bucket is present. With
* month buckets the first and last points may cover only part of a month.
*/
export const getTeamAnalyticsDocumentsOverTime = async ({
userId,
userEmail,
teamId,
range,
from,
to,
timezone,
}: GetTeamAnalyticsDocumentsOverTimeOptions): Promise<TGetTeamAnalyticsDocumentsOverTimeResponse> => {
const scope = await getTeamAnalyticsScope({ teamId, userId, userEmail });
const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone });
const { start, end, bucket } = resolvedRange;
// `createdAt` is a naive TIMESTAMP holding UTC, so it must be tagged as UTC before
// shifting into the request timezone; otherwise Postgres treats it as local time.
const bucketDate = sql<string>`to_char(
date_trunc(${bucket}, (${sql.ref('Envelope.createdAt')} at time zone 'UTC') at time zone ${timezone}),
'YYYY-MM-DD'
)`;
const rows = await kyselyPrisma.$kysely
.selectFrom('Envelope')
.select(({ fn }) => [bucketDate.as('date'), fn.countAll().as('count')])
.where((eb) => scope.applyEnvelopeScope(eb))
.where('Envelope.createdAt', '>=', start)
.where('Envelope.createdAt', '<', end)
.groupBy('date')
.orderBy('date')
.execute();
const countsByDate = new Map(rows.map((row) => [row.date, toTeamAnalyticsCount(row.count)]));
const points: TGetTeamAnalyticsDocumentsOverTimeResponse['points'] = [];
const endTime = DateTime.fromJSDate(end, { zone: timezone });
// Align the cursor to the bucket boundary so it produces the same keys as
// `date_trunc` above. A custom range starting mid-month with month buckets would
// otherwise miss its first (partial) month entirely.
let cursor = DateTime.fromJSDate(start, { zone: timezone }).startOf(bucket);
while (cursor < endTime) {
const date = cursor.toFormat('yyyy-MM-dd');
points.push({
date,
count: countsByDate.get(date) ?? 0,
});
cursor = cursor.plus(bucket === 'month' ? { months: 1 } : { days: 1 });
}
const total = points.reduce((sum, point) => sum + point.count, 0);
return {
range: resolvedRange,
total,
points,
};
};
@@ -1,126 +0,0 @@
import { kyselyPrisma, sql } from '@documenso/prisma';
import type {
TGetTeamAnalyticsMemberActivityRequest,
TGetTeamAnalyticsMemberActivityResponse,
} from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { DocumentStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range';
import {
calculateTeamAnalyticsCompletionRate,
getTeamAnalyticsMembers,
getTeamAnalyticsScope,
toTeamAnalyticsCount,
} from './get-team-analytics-scope';
export type GetTeamAnalyticsMemberActivityOptions = TGetTeamAnalyticsMemberActivityRequest & {
userId: number;
userEmail: string;
};
/**
* Per-member activity for every current team member, scoped to documents the
* caller can see. Members with no visible activity are included with zeros.
*
* A document counts as "sent" by a member when the member owns the envelope and
* it has a DOCUMENT_SENT audit log inside the window. The app logs DOCUMENT_SENT
* once per document, so the earliest log within the window is used as its sent
* time (and drives "last active").
*/
export const getTeamAnalyticsMemberActivity = async ({
userId,
userEmail,
teamId,
range,
from,
to,
timezone,
}: GetTeamAnalyticsMemberActivityOptions): Promise<TGetTeamAnalyticsMemberActivityResponse> => {
const scope = await getTeamAnalyticsScope({ teamId, userId, userEmail });
const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone });
const { start, end } = resolvedRange;
const members = await getTeamAnalyticsMembers(scope.teamId);
if (members.length === 0) {
return {
range: resolvedRange,
members: [],
};
}
const memberUserIds = members.map((member) => member.id);
const rows = await kyselyPrisma.$kysely
.with('scopedEnvelopes', (db) =>
db
.selectFrom('Envelope')
.select(['Envelope.id', 'Envelope.status', 'Envelope.userId'])
.where((eb) => scope.applyEnvelopeScope(eb))
.where('Envelope.userId', 'in', memberUserIds),
)
.with('firstSent', (db) =>
db
.selectFrom('DocumentAuditLog')
.innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'DocumentAuditLog.envelopeId')
.select(({ fn }) => ['DocumentAuditLog.envelopeId', fn.min('DocumentAuditLog.createdAt').as('sentAt')])
.where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT)
.where('DocumentAuditLog.createdAt', '>=', start)
.where('DocumentAuditLog.createdAt', '<', end)
.groupBy('DocumentAuditLog.envelopeId'),
)
.selectFrom('firstSent')
.innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'firstSent.envelopeId')
.select(({ fn, eb }) => [
'scopedEnvelopes.userId',
fn.countAll().as('sent'),
fn
.countAll()
.filterWhere(eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.COMPLETED)))
.as('completed'),
fn
.countAll()
.filterWhere(eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.PENDING)))
.as('pending'),
fn.max('firstSent.sentAt').as('lastActiveAt'),
])
.groupBy('scopedEnvelopes.userId')
.execute();
const activityByUserId = new Map(rows.map((row) => [row.userId, row]));
const memberActivity = members.map((member) => {
const activity = activityByUserId.get(member.id);
const sent = activity ? toTeamAnalyticsCount(activity.sent) : 0;
const completed = activity ? toTeamAnalyticsCount(activity.completed) : 0;
const pending = activity ? toTeamAnalyticsCount(activity.pending) : 0;
return {
userId: member.id,
name: member.name,
email: member.email,
avatarImageId: member.avatarImageId,
sent,
completed,
pending,
completionRate: calculateTeamAnalyticsCompletionRate({ completed, sent }),
lastActiveAt: activity?.lastActiveAt ?? null,
};
});
memberActivity.sort((a, b) => {
if (a.sent !== b.sent) {
return b.sent - a.sent;
}
return (a.name || a.email).localeCompare(b.name || b.email, undefined, { sensitivity: 'base' });
});
return {
range: resolvedRange,
members: memberActivity,
};
};
@@ -1,127 +0,0 @@
import { kyselyPrisma, sql } from '@documenso/prisma';
import type {
TGetTeamAnalyticsOverviewRequest,
TGetTeamAnalyticsOverviewResponse,
} from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range';
import {
calculateTeamAnalyticsCompletionRate,
getTeamAnalyticsMembers,
getTeamAnalyticsScope,
toTeamAnalyticsCount,
} from './get-team-analytics-scope';
export type GetTeamAnalyticsOverviewOptions = TGetTeamAnalyticsOverviewRequest & {
userId: number;
userEmail: string;
};
/**
* Headline team analytics: documents sent, completion rate and member activity
* for the requested window and the window immediately before it.
*
* A document counts as "sent" in a window when its first DOCUMENT_SENT audit log
* falls inside that window.
*/
export const getTeamAnalyticsOverview = async ({
userId,
userEmail,
teamId,
range,
from,
to,
timezone,
}: GetTeamAnalyticsOverviewOptions): Promise<TGetTeamAnalyticsOverviewResponse> => {
const scope = await getTeamAnalyticsScope({ teamId, userId, userEmail });
const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone });
const { start, end, previousStart, previousEnd } = resolvedRange;
const members = await getTeamAnalyticsMembers(scope.teamId);
const memberUserIds = members.map((member) => member.id);
const row = await kyselyPrisma.$kysely
.with('scopedEnvelopes', (db) =>
db
.selectFrom('Envelope')
.select(['Envelope.id', 'Envelope.status', 'Envelope.userId'])
.where((eb) => scope.applyEnvelopeScope(eb)),
)
.with('firstSent', (db) =>
db
.selectFrom('DocumentAuditLog')
.innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'DocumentAuditLog.envelopeId')
.select(({ fn }) => ['DocumentAuditLog.envelopeId', fn.min('DocumentAuditLog.createdAt').as('sentAt')])
.where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT)
.where('DocumentAuditLog.createdAt', '>=', previousStart)
.where('DocumentAuditLog.createdAt', '<', end)
.groupBy('DocumentAuditLog.envelopeId'),
)
.selectFrom('firstSent')
.innerJoin('scopedEnvelopes', 'scopedEnvelopes.id', 'firstSent.envelopeId')
.select(({ fn, eb }) => {
const inCurrentWindow = eb.and([eb('firstSent.sentAt', '>=', start), eb('firstSent.sentAt', '<', end)]);
const inPreviousWindow = eb.and([
eb('firstSent.sentAt', '>=', previousStart),
eb('firstSent.sentAt', '<', previousEnd),
]);
const isCompleted = eb('scopedEnvelopes.status', '=', sql.lit(DocumentStatus.COMPLETED));
// Only current members count as active, so senders who have since left the team
// can never push "active" above "total".
const activeMemberFilter =
memberUserIds.length > 0
? eb.and([inCurrentWindow, eb('scopedEnvelopes.userId', 'in', memberUserIds)])
: inCurrentWindow;
return [
fn.countAll().filterWhere(inCurrentWindow).as('sentCurrent'),
fn.countAll().filterWhere(inPreviousWindow).as('sentPrevious'),
fn
.countAll()
.filterWhere(eb.and([inCurrentWindow, isCompleted]))
.as('completedCurrent'),
fn
.countAll()
.filterWhere(eb.and([inPreviousWindow, isCompleted]))
.as('completedPrevious'),
fn.count('scopedEnvelopes.userId').distinct().filterWhere(activeMemberFilter).as('activeMembers'),
];
})
.executeTakeFirst();
if (!row) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Analytics overview query returned no result',
});
}
const sentCurrent = toTeamAnalyticsCount(row.sentCurrent);
const sentPrevious = toTeamAnalyticsCount(row.sentPrevious);
const completedCurrent = toTeamAnalyticsCount(row.completedCurrent);
const completedPrevious = toTeamAnalyticsCount(row.completedPrevious);
return {
range: resolvedRange,
sent: {
current: sentCurrent,
previous: sentPrevious,
},
completionRate: {
completed: completedCurrent,
sent: sentCurrent,
rate: calculateTeamAnalyticsCompletionRate({ completed: completedCurrent, sent: sentCurrent }),
previousRate: calculateTeamAnalyticsCompletionRate({ completed: completedPrevious, sent: sentPrevious }),
},
members: {
total: memberUserIds.length,
active: memberUserIds.length === 0 ? 0 : toTeamAnalyticsCount(row.activeMembers),
},
};
};
@@ -1,202 +0,0 @@
import { prisma, sql } from '@documenso/prisma';
import type { DB } from '@documenso/prisma/generated/types';
import type { Prisma } from '@prisma/client';
import { EnvelopeType, TeamMemberRole } from '@prisma/client';
import type { Expression, ExpressionBuilder, SqlBool } from 'kysely';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery, getHighestTeamRoleInGroup } from '../../utils/teams';
export type GetTeamAnalyticsScopeOptions = {
teamId: number;
userId: number;
userEmail: string;
};
export type EnvelopeScopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
export type ApplyEnvelopeScope = (eb: EnvelopeScopeExpressionBuilder) => Expression<SqlBool>;
/**
* Authorise the caller for team analytics (ADMIN or MANAGER) and build the
* envelope visibility scope used by every analytics procedure.
*
* The visibility rule mirrors `findDocuments`: an envelope is visible when its
* visibility meets the caller's role threshold, the caller owns it, or the caller
* is a recipient. Only non-deleted team documents are considered.
*/
export const getTeamAnalyticsScope = async ({ teamId, userId, userEmail }: GetTeamAnalyticsScopeOptions) => {
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
}),
select: {
id: true,
teamGroups: {
where: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to view analytics for this team',
});
}
const role = getHighestTeamRoleInGroup(team.teamGroups);
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[role];
const envelopeWhere: Prisma.EnvelopeWhereInput = {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
deletedAt: null,
OR: [{ visibility: { in: allowedVisibilities } }, { userId }, { recipients: { some: { email: userEmail } } }],
};
/**
* Kysely predicate equivalent of `envelopeWhere`, for use in `Envelope` queries
* that need aggregates Prisma cannot express.
*/
const applyEnvelopeScope: ApplyEnvelopeScope = (eb) =>
eb.and([
eb('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
eb('Envelope.teamId', '=', team.id),
eb('Envelope.deletedAt', 'is', null),
eb.or([
eb(
'Envelope.visibility',
'in',
allowedVisibilities.map((visibility) => sql.lit(visibility)),
),
eb('Envelope.userId', '=', userId),
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.email', '=', userEmail)
.select(sql.lit(1).as('one')),
),
]),
]);
return {
teamId: team.id,
userId,
userEmail,
role,
allowedVisibilities,
envelopeWhere,
applyEnvelopeScope,
};
};
export type TeamAnalyticsScope = Awaited<ReturnType<typeof getTeamAnalyticsScope>>;
export type TeamAnalyticsMember = {
id: number;
name: string | null;
email: string;
avatarImageId: string | null;
};
/**
* Distinct users who are current members of the team (attached through any of
* the team's organisation groups).
*/
export const getTeamAnalyticsMembers = async (teamId: number): Promise<TeamAnalyticsMember[]> => {
const members = await prisma.organisationMember.findMany({
where: {
organisationGroupMembers: {
some: {
group: {
teamGroups: {
some: {
teamId,
},
},
},
},
},
},
select: {
user: {
select: {
id: true,
name: true,
email: true,
avatarImageId: true,
},
},
},
});
const membersByUserId = new Map<number, TeamAnalyticsMember>();
for (const member of members) {
if (!membersByUserId.has(member.user.id)) {
membersByUserId.set(member.user.id, member.user);
}
}
return Array.from(membersByUserId.values());
};
/**
* Completion percentage (0-100) rounded to one decimal, or null when nothing was sent.
*/
export const calculateTeamAnalyticsCompletionRate = ({
completed,
sent,
}: {
completed: number;
sent: number;
}): number | null => {
if (sent === 0) {
return null;
}
return Math.round((completed / sent) * 1000) / 10;
};
const MAX_SAFE_COUNT = BigInt(Number.MAX_SAFE_INTEGER);
/**
* Convert a Postgres COUNT into a JS number, throwing if the value cannot be
* represented safely.
*
* Depending on the driver, Kysely surfaces `bigint` columns as `bigint`, `string`
* or `number`, so all three are normalised here.
*/
export const toTeamAnalyticsCount = (count: string | number | bigint): number => {
let value: bigint;
try {
value = BigInt(count);
} catch {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Analytics count is not an integer',
});
}
if (value < BigInt(0) || value > MAX_SAFE_COUNT) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Analytics count exceeds the safe integer range',
});
}
return Number(value);
};
@@ -1,70 +0,0 @@
import { prisma } from '@documenso/prisma';
import type {
TGetTeamAnalyticsStatusBreakdownRequest,
TGetTeamAnalyticsStatusBreakdownResponse,
} from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { DocumentStatus } from '@prisma/client';
import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range';
import { getTeamAnalyticsScope } from './get-team-analytics-scope';
export type GetTeamAnalyticsStatusBreakdownOptions = TGetTeamAnalyticsStatusBreakdownRequest & {
userId: number;
userEmail: string;
};
/**
* Current status of every visible document created inside the requested window.
*/
export const getTeamAnalyticsStatusBreakdown = async ({
userId,
userEmail,
teamId,
range,
from,
to,
timezone,
}: GetTeamAnalyticsStatusBreakdownOptions): Promise<TGetTeamAnalyticsStatusBreakdownResponse> => {
const scope = await getTeamAnalyticsScope({ teamId, userId, userEmail });
const resolvedRange = resolveTeamAnalyticsRange({ range, from, to, timezone });
const { start, end } = resolvedRange;
const groups = await prisma.envelope.groupBy({
by: ['status'],
where: {
...scope.envelopeWhere,
createdAt: {
gte: start,
lt: end,
},
},
_count: {
_all: true,
},
});
const counts: Record<DocumentStatus, number> = {
[DocumentStatus.DRAFT]: 0,
[DocumentStatus.PENDING]: 0,
[DocumentStatus.COMPLETED]: 0,
[DocumentStatus.REJECTED]: 0,
[DocumentStatus.CANCELLED]: 0,
};
for (const group of groups) {
counts[group.status] = group._count._all;
}
const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
return {
range: resolvedRange,
total,
draft: counts[DocumentStatus.DRAFT],
pending: counts[DocumentStatus.PENDING],
completed: counts[DocumentStatus.COMPLETED],
rejected: counts[DocumentStatus.REJECTED],
cancelled: counts[DocumentStatus.CANCELLED],
};
};

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