Compare commits

...
Author SHA1 Message Date
Ephraim Duncan a6d4bbd8bc Merge branch 'main' into refactor/trpc-error-formatter 2026-09-24 11:29:49 +00:00
Lucas Smith 39ae85483a fix: add date-fns to packages/ui (#3388) 2026-09-24 10:29:26 +10:00
Ephraim Duncan 638e92d534 feat: add team document analytics dashboard (#3355) 2026-09-24 09:50:35 +10:00
Catalin Pit c81bc72c4c fix: bulk download dialog overflow on long titles (#3380) 2026-09-21 19:23:11 +10:00
Zonaib Bokhari 1164d9578e Respect hidden sender details in document invite emails (#3061) 2026-09-21 09:20:39 +03:00
David Nguyen e658cc5818 feat: add inbox filters (#3372) 2026-09-17 17:21:34 +10:00
Ephraim Duncan 0c6249744a feat(ui): redesign recipient avatar stack hover popover (#3072) 2026-09-17 13:36:15 +10:00
Ephraim Duncan cf326f825d feat: replace template view tabs with a filter pill (#3148) 2026-09-17 10:24:19 +10:00
Ephraim Duncan da934c01a6 feat: allow signing reason override (#2848) 2026-09-16 14:41:55 +10:00
Ephraim Duncan 0693a4195b refactor(openpage): simplify the cors policy (#3338) 2026-09-16 14:10:55 +10:00
Ephraim Duncan 2618d4d4be chore(embedding): remove unregistered multi-sign mutation (#3339) 2026-09-16 14:07:04 +10:00
Ephraim Duncan c5a6ff42ee chore(ui): remove unused application skeletons (#3340) 2026-09-16 14:06:03 +10:00
Ephraim Duncan 132c4b08c5 fix(trpc): v2 field position updates silently dropped; rewrite fields API docs (#3136) 2026-09-16 13:59:40 +10:00
Ephraim Duncan 9542512ce9 refactor(lib): reuse the http url validator (#3346) 2026-09-16 13:54:52 +10:00
Ephraim Duncan 0f71a8b67d chore(prisma): remove unregistered middleware (#3345) 2026-09-16 13:53:19 +10:00
Ephraim Duncan b795883fc7 chore(lib): remove unused timezone labels (#3342) 2026-09-16 13:48:03 +10:00
Ephraim Duncan 643954ddd1 refactor(server): simplify request context middleware (#3341) 2026-09-16 13:46:01 +10:00
Catalin Pit a20e8a2376 feat: add document naming options when using templates (#3086) 2026-09-16 01:00:40 +10:00
ephraimduncan c942ad4a2c chore: remove generated translation catalogs from branch 2026-08-15 14:02:51 +00:00
ephraimduncan c8ff704467 refactor(trpc): tighten error formatter and route meta types 2026-08-15 13:50:32 +00:00
123 changed files with 10477 additions and 1219 deletions
+2
View File
@@ -80,6 +80,8 @@ 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,6 +15,8 @@ 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. |
@@ -37,7 +39,28 @@ 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
+193 -88
View File
@@ -6,6 +6,8 @@ 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).
@@ -19,13 +21,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` | number | ID of the parent envelope |
| `envelopeId` | string | ID of the parent envelope |
| `envelopeItemId` | string | ID of the PDF item the field is placed on |
| `page` | number | Page number (1-indexed) |
| `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) |
| `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 |
| `customText` | string | Value entered by the recipient |
| `inserted` | boolean | Whether the field has been completed |
| `fieldMeta` | object \| null | Type-specific configuration options |
@@ -38,18 +40,19 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
"secondaryId": "field_abc123",
"type": "SIGNATURE",
"recipientId": 123,
"envelopeId": 789,
"envelopeItemId": "envelope_item_xyz",
"envelopeId": "envelope_abcdefhiklmnorst",
"envelopeItemId": "envelope_item_abcdefhiklmnorst",
"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
"required": true,
"overflow": "auto"
}
}
```
@@ -61,7 +64,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
| Type | Description | Auto-filled |
| ---------------- | ----------------------------------------- | ----------- |
| `SIGNATURE` | Drawn, typed, or uploaded signature | No |
| `FREE_SIGNATURE` | Unrestricted signature without validation | 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 |
| `INITIALS` | Recipient's initials | No |
| `NAME` | Recipient's full name | Yes |
| `EMAIL` | Recipient's email address | Yes |
@@ -134,10 +137,12 @@ POST /envelope/field/create-many
### Request Body
| Field | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------- |
| `documentId`| number | Yes | The document ID |
| `fields` | array | Yes | Array of field configurations |
| 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`.
### Code Examples
@@ -148,32 +153,32 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/create-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"documentId": 123,
"fields": [
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"type": "SIGNATURE",
"recipientId": 456,
"pageNumber": 1,
"pageX": 10,
"pageY": 80,
"page": 1,
"positionX": 10,
"positionY": 80,
"width": 30,
"height": 5
},
{
"type": "DATE",
"recipientId": 456,
"pageNumber": 1,
"pageX": 50,
"pageY": 80,
"page": 1,
"positionX": 50,
"positionY": 80,
"width": 20,
"height": 3
},
{
"type": "TEXT",
"recipientId": 456,
"pageNumber": 1,
"pageX": 10,
"pageY": 70,
"page": 1,
"positionX": 10,
"positionY": 70,
"width": 40,
"height": 4,
"fieldMeta": {
@@ -199,32 +204,32 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId: 123,
fields: [
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
type: 'SIGNATURE',
recipientId: 456,
pageNumber: 1,
pageX: 10,
pageY: 80,
page: 1,
positionX: 10,
positionY: 80,
width: 30,
height: 5,
},
{
type: 'DATE',
recipientId: 456,
pageNumber: 1,
pageX: 50,
pageY: 80,
page: 1,
positionX: 50,
positionY: 80,
width: 20,
height: 3,
},
{
type: 'TEXT',
recipientId: 456,
pageNumber: 1,
pageX: 10,
pageY: 70,
page: 1,
positionX: 10,
positionY: 70,
width: 40,
height: 4,
fieldMeta: {
@@ -239,8 +244,8 @@ const response = await fetch(
}
);
const { fields } = await response.json();
console.log(`Created ${fields.length} fields`);
const { data } = await response.json();
console.log(`Created ${data.length} fields`);
````
</Tab>
@@ -250,36 +255,68 @@ console.log(`Created ${fields.length} fields`);
```json
{
"fields": [
"data": [
{
"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
"positionX": "10",
"positionY": "80",
"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": 80,
"width": 20,
"height": 3
"positionX": "50",
"positionY": "80",
"width": "20",
"height": "3",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "date",
"fontSize": 12,
"textAlign": "left",
"overflow": "auto"
}
},
{
"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
"positionX": "10",
"positionY": "70",
"width": "40",
"height": "4",
"customText": "",
"inserted": false,
"fieldMeta": {
"type": "text",
"label": "Job Title",
"placeholder": "Enter your job title",
"required": true
}
}
]
}
@@ -299,8 +336,10 @@ POST /envelope/field/update-many
| Field | Type | Required | Description |
| ------------ | ------ | -------- | ----------------------------- |
| `documentId` | number | Yes | The document ID |
| `fields` | array | Yes | Array of field update objects |
| `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.
### Code Examples
@@ -311,17 +350,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/field/update-many" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"documentId": 123,
"fields": [
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"id": 101,
"type": "SIGNATURE",
"pageY": 85
"positionY": 85
},
{
"id": 102,
"type": "DATE",
"pageY": 85
"positionY": 85
}
]
}'
@@ -338,16 +377,16 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
documentId: 123,
fields: [
{ id: 101, type: 'SIGNATURE', pageY: 85 },
{ id: 102, type: 'DATE', pageY: 85 },
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{ id: 101, type: 'SIGNATURE', positionY: 85 },
{ id: 102, type: 'DATE', positionY: 85 },
],
}),
}
);
const { fields } = await response.json();
const { data } = await response.json();
````
</Tab>
@@ -357,9 +396,48 @@ const { fields } = await response.json();
```json
{
"fields": [
{ "id": 101, "type": "SIGNATURE", "positionY": 85 },
{ "id": 102, "type": "DATE", "positionY": 85 }
"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"
}
}
]
}
````
@@ -443,8 +521,8 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
(0,0) ─────────────────────────── (100,0)
│ │
│ ┌─────────┐ │
│ │ Field │ (pageX: 10, │
│ │ │ pageY: 20, │
│ │ Field │ (positionX: 10, │
│ │ │ positionY: 20, │
│ └─────────┘ width: 30, │
│ height: 5) │
│ │
@@ -457,9 +535,9 @@ Fields use percentage-based coordinates relative to the PDF page dimensions.
const field = {
type: 'SIGNATURE',
recipientId: 123,
pageNumber: 1,
pageX: 60, // 60% from left
pageY: 85, // 85% from top (near bottom)
page: 1,
positionX: 60, // 60% from left
positionY: 85, // 85% from top (near bottom)
width: 30, // 30% of page width
height: 8, // 8% of page height
};
@@ -479,6 +557,33 @@ 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
@@ -643,15 +748,15 @@ All field types support these base options:
Create a document with a signature block containing multiple field types:
```typescript
async function addSignatureBlock(documentId: number, recipientId: number) {
const fields = [
async function addSignatureBlock(envelopeId: string, recipientId: number) {
const data = [
// Signature
{
type: 'SIGNATURE',
recipientId,
pageNumber: 1,
pageX: 10,
pageY: 80,
page: 1,
positionX: 10,
positionY: 80,
width: 30,
height: 8,
fieldMeta: {
@@ -663,9 +768,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'NAME',
recipientId,
pageNumber: 1,
pageX: 10,
pageY: 90,
page: 1,
positionX: 10,
positionY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -677,9 +782,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'DATE',
recipientId,
pageNumber: 1,
pageX: 50,
pageY: 80,
page: 1,
positionX: 50,
positionY: 80,
width: 20,
height: 4,
fieldMeta: {
@@ -691,9 +796,9 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
{
type: 'TEXT',
recipientId,
pageNumber: 1,
pageX: 50,
pageY: 90,
page: 1,
positionX: 50,
positionY: 90,
width: 30,
height: 4,
fieldMeta: {
@@ -710,7 +815,7 @@ async function addSignatureBlock(documentId: number, recipientId: number) {
Authorization: 'api_xxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({ documentId, fields }),
body: JSON.stringify({ envelopeId, data }),
});
return response.json();
@@ -60,8 +60,8 @@ Authorization: api_xxxxxxxxxxxxxxxx
href="/docs/developers/api/templates"
/>
<Card
title="Teams"
description="Manage teams and team members."
title="Team-scoped access"
description="Use team-scoped API tokens with envelope endpoints."
href="/docs/developers/api/teams"
/>
</Cards>
@@ -6,6 +6,8 @@ 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).
@@ -16,7 +18,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
```json
{
"id": 123,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -134,7 +136,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": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"email": "signer@example.com",
@@ -164,7 +166,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'signer@example.com',
@@ -196,7 +198,7 @@ const { data: recipients } = await response.json();
"data": [
{
"id": 789,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -209,7 +211,7 @@ const { data: recipients } = await response.json();
},
{
"id": 790,
"envelopeId": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "approver@example.com",
"name": "Jane Smith",
"role": "APPROVER",
@@ -262,7 +264,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": "clu1abc2def3ghi4jkl",
"envelopeId": "envelope_abcdefhiklmnorst",
"data": [
{
"id": 789,
@@ -284,7 +286,7 @@ const response = await fetch(
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
id: 789,
@@ -387,7 +389,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'approver@example.com',
@@ -462,7 +464,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/recipien
'Content-Type': 'application/json',
},
body: JSON.stringify({
envelopeId: 'clu1abc2def3ghi4jkl',
envelopeId: 'envelope_abcdefhiklmnorst',
data: [
{
email: 'signer@example.com',
+29 -42
View File
@@ -1,44 +1,29 @@
---
title: Teams API
description: Manage team resources, documents, and templates with team-scoped API tokens.
title: Team-Scoped API Access
description: Use team-scoped API tokens with document and template envelopes.
---
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 Object
## Team Context
A team object contains the following properties:
<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>
| 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"
}
```
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.
## Team-Scoped API Tokens
@@ -156,26 +141,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" \
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
# Filter by status
curl -X GET "https://app.documenso.com/api/v2/envelope?status=PENDING" \
curl -X GET "https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=PENDING" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
},
});
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} team documents`);
const { data, count } = await response.json();
console.log(`Found ${count} team documents`);
````
</Tab>
@@ -190,10 +175,11 @@ 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/template/create" \
curl -X POST "https://app.documenso.com/api/v2/envelope/create" \
-H "Authorization: api_xxxxxxxxxxxxxxxx" \
-H "Content-Type: multipart/form-data" \
-F 'payload={
"type": "TEMPLATE",
"title": "NDA Template",
"recipients": [
{
@@ -223,6 +209,7 @@ curl -X POST "https://app.documenso.com/api/v2/template/create" \
const form = new FormData();
const payload = {
type: 'TEMPLATE',
title: 'NDA Template',
recipients: [
{
@@ -249,7 +236,7 @@ form.append('files', fs.createReadStream('./nda-template.pdf'), {
contentType: 'application/pdf',
});
const response = await fetch('https://app.documenso.com/api/v2/template/create', {
const response = await fetch('https://app.documenso.com/api/v2/envelope/create', {
method: 'POST',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -257,8 +244,8 @@ const response = await fetch('https://app.documenso.com/api/v2/template/create',
body: form,
});
const template = await response.json();
console.log('Created team template:', template.id);
const { id } = await response.json();
console.log('Created team template envelope:', id);
````
</Tab>
</Tabs>
@@ -268,14 +255,14 @@ console.log('Created team template:', template.id);
<Tabs items={['curl', 'TypeScript']}>
<Tab value="curl">
```bash
curl -X GET "https://app.documenso.com/api/v2/template" \
curl -X GET "https://app.documenso.com/api/v2/envelope?type=TEMPLATE" \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
````
</Tab>
<Tab value="TypeScript">
```typescript
const response = await fetch('https://app.documenso.com/api/v2/template', {
const response = await fetch('https://app.documenso.com/api/v2/envelope?type=TEMPLATE', {
method: 'GET',
headers: {
Authorization: TEAM_API_TOKEN,
@@ -330,19 +317,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?status=PENDING', {
const salesResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&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?status=COMPLETED', {
const legalResponse = await fetch('https://app.documenso.com/api/v2/envelope?type=DOCUMENT&status=COMPLETED', {
headers: { Authorization: LEGAL_TEAM_TOKEN },
});
const legalDocs = await legalResponse.json();
console.log(`Sales team: ${salesDocs.pagination.totalItems} pending`);
console.log(`Legal team: ${legalDocs.pagination.totalItems} completed`);
console.log(`Sales team: ${salesDocs.count} pending`);
console.log(`Legal team: ${legalDocs.count} completed`);
```
## Error Responses
@@ -13,7 +13,194 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
see the [OpenAPI Reference](https://openapi.documenso.com).
</Callout>
## Template Object
## 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
A template object contains the following properties:
@@ -91,7 +278,7 @@ A template object contains the following properties:
}
```
## List Templates
## List Templates (Deprecated)
Retrieve a paginated list of templates.
@@ -139,8 +326,8 @@ const response = await fetch(`${BASE_URL}/template`, {
},
});
const { data, pagination } = await response.json();
console.log(`Found ${pagination.totalItems} templates`);
const { data, count } = await response.json();
console.log(`Found ${count} templates`);
// Filter by type
const privateResponse = await fetch(
@@ -181,18 +368,16 @@ const privateTemplates = await privateResponse.json();
]
}
],
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 3,
"totalItems": 25
}
"count": 25,
"currentPage": 1,
"perPage": 10,
"totalPages": 3
}
```
---
## Get Template
## Get Template (Deprecated)
Retrieve a single template by ID.
@@ -238,9 +423,9 @@ Returns the full template object including recipients, fields, and metadata.
---
## Create Document from Template
## Create Document from Template (Deprecated)
Create a new document using a template. This is the primary way to use templates programmatically.
Create a new document using the deprecated template endpoint.
<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.
@@ -415,32 +600,57 @@ const prefilledDocument = await prefillResponse.json();
### Response
Returns the created document object with recipients and signing URLs.
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`.
```json
{
"id": "envelope_xyz789",
"type": "DOCUMENT",
"id": 789,
"envelopeId": "envelope_xyz789",
"status": "PENDING",
"title": "Employment Contract",
"source": "TEMPLATE",
"title": "Employment Contract",
"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",
"signingUrl": "https://app.documenso.com/sign/abc123"
"signingOrder": 1
}
]
}
````
```
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
## Override Template Settings (Deprecated)
When creating a document from a template, you can override various settings:
@@ -488,7 +698,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Prefill Fields
## Prefill Fields (Deprecated)
Prefill field values when creating a document from a template. This is useful for populating known data before sending.
@@ -577,7 +787,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
---
## Update Template
## Update Template (Deprecated)
Update a template's properties.
@@ -643,7 +853,7 @@ const template = await response.json();
---
## Duplicate Template
## Duplicate Template (Deprecated)
Create a copy of an existing template.
@@ -695,7 +905,7 @@ console.log('New template ID:', duplicatedTemplate.id);
---
## Delete Template
## Delete Template (Deprecated)
Delete a template.
@@ -754,7 +964,7 @@ const { success } = await response.json();
---
## Direct Link Templates
## Direct Link Templates (Deprecated)
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.
@@ -898,7 +1108,7 @@ const { success } = await response.json();
---
## Custom Document Data
## Custom Document Data (Deprecated)
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.
@@ -913,7 +1123,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Template Types
## Template Types (Legacy)
| Type | Description |
| --------- | ------------------------------------------------------------------ |
@@ -922,7 +1132,7 @@ See the [OpenAPI Reference](https://openapi.documenso.com) for the full request
---
## Complete Example: Contract Workflow
## Complete Legacy Example: Contract Workflow (Deprecated)
This example demonstrates a complete workflow for using templates to send contracts.
@@ -996,16 +1206,29 @@ async function sendEmploymentContract(employeeData: {
subject: `Employment Contract for ${employeeData.name}`,
message: `Hi ${employeeData.name},\n\nPlease review and sign your employment contract.`,
},
distributeDocument: true,
distributeDocument: false,
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 {
documentId: document.id,
signingUrl: document.recipients[0].signingUrl,
envelopeId: document.envelopeId,
signingUrl: distribution.recipients[0].signingUrl,
};
}
@@ -1018,7 +1241,7 @@ const result = await sendEmploymentContract({
startDate: '2025-03-01',
});
console.log('Document created:', result.documentId);
console.log('Document created:', result.envelopeId);
console.log('Signing URL:', result.signingUrl);
````
@@ -262,8 +262,11 @@ 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, prefill data, and{' '}
<code>distributeDocument: true</code>
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
</Step>
</Steps>
@@ -297,7 +300,7 @@ type TemplateRecipient = {
async function sendEmploymentContract(
templateId: number,
employee: EmployeeData,
): Promise<{ documentId: string; signingUrl: string }> {
): Promise<{ documentId: number; signingUrl: string }> {
const templateResponse = await fetch(`${BASE_URL}/template/${templateId}`, {
headers: { Authorization: API_TOKEN },
});
@@ -374,7 +377,6 @@ 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]}`,
}),
});
@@ -386,9 +388,25 @@ 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: document.recipients[0].signingUrl,
signingUrl: distributeResult.recipients[0].signingUrl,
};
}
@@ -453,12 +471,17 @@ 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\"
}")
echo "Document created:"
echo $RESPONSE | jq '{id, signingUrl: .recipients[0].signingUrl}'
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')"
````
</Tab>
@@ -476,8 +499,10 @@ 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> with{' '}
<code>distributeDocument: true</code>
For each recipient, call <code>POST /template/use</code>
</Step>
<Step>
Distribute each returned envelope via <code>POST /envelope/distribute</code>
</Step>
<Step>
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
@@ -540,7 +565,6 @@ async function bulkSendFromTemplate(
recipients: [
{ id: signerSlot.id, email: recipient.email, name: recipient.name },
],
distributeDocument: true,
externalId: `bulk-${Date.now()}-${recipient.email}`,
}),
});
@@ -551,10 +575,26 @@ 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.id,
signingUrl: document.recipients[0].signingUrl,
envelopeId: document.envelopeId,
signingUrl: distributeResult.recipients[0].signingUrl,
};
}),
);
@@ -627,14 +667,24 @@ for RECIPIENT in "${RECIPIENTS[@]}"; do
\"email\": \"${EMAIL}\",
\"name\": \"${NAME}\"
}],
\"distributeDocument\": true,
\"externalId\": \"bulk-$(date +%s)-${EMAIL}\"
}")
if echo $RESPONSE | jq -e '.id' > /dev/null 2>&1; then
echo " Success: $(echo $RESPONSE | jq -r '.id')"
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
else
echo " Failed: $(echo $RESPONSE | jq -r '.message')"
echo " Failed to create: $(echo $RESPONSE | jq -r '.message')"
fi
# Rate limiting delay
@@ -837,13 +887,15 @@ 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 = 'signed' | 'original';
type DownloadVersion = 'original' | 'pending' | 'signed';
async function downloadDocument(
envelopeId: string,
@@ -897,7 +949,7 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
{ headers: { Authorization: API_TOKEN } },
);
const { data, pagination } = await response.json();
const { data, count, currentPage, perPage, totalPages } = await response.json();
for (const envelope of data) {
try {
@@ -911,8 +963,8 @@ async function downloadAllCompletedDocuments(outputDir: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 500));
}
hasMore = page < pagination.totalPages;
page++;
hasMore = currentPage < totalPages;
page = currentPage + 1;
}
}
@@ -24,20 +24,18 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
{/* prettier-ignore */}
<Steps>
<Step>
### Open settings
### Select 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)
Log in to Documenso and open the team that the integration should access. API tokens are scoped to a
team.
</Step>
<Step>
### Navigate to the API Tokens tab
### Open API Tokens
Go to **Settings** and open the **API Tokens** tab.
Go to **Team Settings** → **API Tokens**, or open
`/t/{teamUrl}/settings/tokens` after replacing `{teamUrl}` with your team's URL.
![API tokens page](/public-api-images/api-tokens-page-documenso.webp)
@@ -48,7 +46,7 @@ Go to **Settings** and open the **API Tokens** tab.
- Click **Create Token**
- Enter a descriptive name (e.g., `production-backend`, `zapier-integration`)
- Select an expiration period: never expires, 7 days, 1 month, 3 months, 6 months, or 1 year
- Select an expiration period: 7 days, 1 month, 3 months, 6 months, 12 months, or Never
- Click **Create Token**
</Step>
@@ -75,21 +73,21 @@ Include the token in the `Authorization` header of your HTTP requests.
### cURL
```bash
curl https://app.documenso.com/api/v2/document \
curl https://app.documenso.com/api/v2/envelope \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
```
### JavaScript / TypeScript
```typescript
const response = await fetch('https://app.documenso.com/api/v2/document', {
const response = await fetch('https://app.documenso.com/api/v2/envelope', {
method: 'GET',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
},
});
const documents = await response.json();
const envelopes = await response.json();
```
### Using the TypeScript SDK
@@ -129,7 +127,7 @@ SDKs are available for [TypeScript](https://github.com/documenso/sdk-typescript)
## Token Security
API tokens grant full access to your account. Follow these practices to keep them secure:
API tokens grant full API access to the team they were created for. 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.
@@ -155,12 +153,11 @@ const client = new Documenso({
## Token Scope
API tokens have full access to your account, including:
API tokens have full API access to the team they were created for, 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.
@@ -171,7 +168,7 @@ To revoke a token:
{/* prettier-ignore */}
<Steps>
<Step>
Go to **Settings** > **API Tokens**
Go to **Team Settings** → **API Tokens**
</Step>
<Step>
Find the token you want to revoke
@@ -194,7 +191,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 account.
Ensure you're accessing resources owned by the token's team.
</Accordion>
</Accordions>
@@ -78,12 +78,10 @@ A successful response returns a list of your documents (envelopes):
"createdAt": "2025-01-15T10:30:00.000Z"
}
],
"pagination": {
"page": 1,
"perPage": 10,
"totalPages": 1,
"totalItems": 1
}
"count": 1,
"currentPage": 1,
"perPage": 10,
"totalPages": 1
}
````
@@ -228,9 +226,12 @@ 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/envelope_abc123/distribute" \
curl -X POST "https://app.documenso.com/api/v2/envelope/distribute" \
-H "Authorization: YOUR_API_TOKEN" \
-H "Content-Type: application/json"
-H "Content-Type: application/json" \
-d '{
"envelopeId": "envelope_abc123"
}'
````
</Tab>
@@ -238,16 +239,14 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/envelope_abc123/distribu
```javascript
const envelopeId = 'envelope_abc123';
const response = await fetch(
`https://app.documenso.com/api/v2/envelope/${envelopeId}/distribute`,
{
method: 'POST',
headers: {
Authorization: 'YOUR_API_TOKEN',
'Content-Type': 'application/json',
},
const response = await fetch('https://app.documenso.com/api/v2/envelope/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);
@@ -337,16 +336,14 @@ 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/${envelope.id}/distribute`,
{
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
}
);
const distributeResponse = await fetch(`${BASE_URL}/envelope/distribute`, {
method: 'POST',
headers: {
'Authorization': API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId: envelope.id }),
});
if (!distributeResponse.ok) {
const error = await distributeResponse.json();
@@ -422,9 +419,12 @@ echo "Created envelope: ${ENVELOPE_ID}"
# Step 2: Send the document for signing
echo "Sending document..."
curl -s -X POST "${BASE_URL}/envelope/${ENVELOPE_ID}/distribute" \
curl -s -X POST "${BASE_URL}/envelope/distribute" \
-H "Authorization: ${API_TOKEN}" \
-H "Content-Type: application/json"
-H "Content-Type: application/json" \
-d "{
\"envelopeId\": \"${ENVELOPE_ID}\"
}"
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 limited - wait 60 seconds and retry |
| `429` | Rate limit or plan quota. If `Retry-After` is present, retry the request. |
| `500` | Server error - retry or contact support |
### Error Response Format
@@ -485,7 +485,9 @@ The API returns standard HTTP status codes and JSON error responses:
### Handling Rate Limits
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:
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.
```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
@@ -493,8 +495,15 @@ async function fetchWithRetry(url, options, maxRetries = 3) {
const response = await fetch(url, options);
if (response.status === 429) {
console.log('Rate limited, waiting 60 seconds...');
await new Promise((resolve) => setTimeout(resolve, 60000));
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));
continue;
}
+9 -128
View File
@@ -1,142 +1,23 @@
/**
* 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
* Apply the public statistics API's wildcard CORS policy, without credentials.
*/
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 };
export default async function cors(req: Request, res: Response): Promise<Response> {
const { headers } = res;
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);
}
};
headers.set('Access-Control-Allow-Origin', '*');
// 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') {
if (opts.methods) {
const methods = Array.isArray(opts.methods) ? opts.methods.join(',') : opts.methods;
headers.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE');
headers.set('Vary', 'Access-Control-Request-Headers');
headers.set('Access-Control-Allow-Methods', methods);
}
const allowedHeaders = req.headers.get('Access-Control-Request-Headers');
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;
if (allowedHeaders) {
headers.set('Access-Control-Allow-Headers', allowedHeaders);
}
headers.set('Content-Length', '0');
return new Response(null, { status: opts.optionsSuccessStatus, headers });
return new Response(null, { status: 204, 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);
}
@@ -44,7 +44,10 @@ export const EnvelopesBulkDeleteDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
}
if (result.failedIds.length > 0) {
@@ -299,7 +299,7 @@ export const EnvelopesBulkDownloadDialog = ({
</Alert>
)}
<fieldset disabled={isDownloading} className="space-y-4">
<fieldset disabled={isDownloading} className="min-w-0 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,7 +96,10 @@ export const EnvelopesBulkMoveDialog = ({
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
}
await onSuccess?.(data.folderId);
@@ -8,6 +8,7 @@ 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';
@@ -23,6 +24,7 @@ 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';
@@ -38,27 +40,64 @@ import { useNavigate } from 'react-router';
import * as z from 'zod';
import { getTemplateUseErrorMessage } from '~/utils/toast-error-messages';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.array(
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(
z.object({
title: z.string(),
data: z.instanceof(File).optional(),
envelopeItemId: z.string(),
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
),
});
),
})
.refine((data) => data.documentNameSource !== DOCUMENT_NAME_SOURCE.CUSTOM || data.customDocumentName.length > 0, {
message: msg`Document name is required`.id,
path: ['customDocumentName'],
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@@ -87,6 +126,7 @@ 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(
{
@@ -106,6 +146,8 @@ export function TemplateUseDialog({
return {
distributeDocument: false,
useCustomDocument: false,
documentNameSource: DOCUMENT_NAME_SOURCE.TEMPLATE,
customDocumentName: '',
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
@@ -140,11 +182,39 @@ 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 customFilesToUpload = (data.customDocumentData || []).filter(
(item): item is { data: File; envelopeItemId: string; title: string } =>
item.data !== undefined && item.envelopeItemId !== undefined && item.title !== undefined,
const documentTitle = getDocumentTitle(data);
const customFilesToUpload = (data.customDocumentData ?? []).filter(
(item): item is typeof item & { data: File } => item.data !== undefined,
);
const customDocumentData = await Promise.all(
@@ -163,6 +233,7 @@ export function TemplateUseDialog({
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentData,
...(documentTitle ? { override: { title: documentTitle } } : {}),
});
toast({
@@ -195,9 +266,14 @@ 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]);
@@ -238,9 +314,9 @@ export function TemplateUseDialog({
</DialogHeader>
<Form {...form}>
<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">
<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">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
@@ -401,7 +477,17 @@ export function TemplateUseDialog({
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue('customDocumentData', undefined);
const customDocumentData = form.getValues('customDocumentData');
form.setValue(
'customDocumentData',
customDocumentData?.map((item) => ({
...item,
data: undefined,
})),
);
form.clearErrors('customDocumentData');
updateLastUploadedFile(undefined);
}
}}
/>
@@ -428,7 +514,7 @@ export function TemplateUseDialog({
)}
/>
{form.watch('useCustomDocument') && (
{useCustomDocument && (
<div className="my-4 space-y-2">
{isLoadingEnvelopeItems ? (
<SpinnerBox className="py-16" />
@@ -443,7 +529,7 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
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"
>
<div className="flex-shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
@@ -451,13 +537,15 @@ export function TemplateUseDialog({
</div>
</div>
<div className="min-w-0 flex-1">
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
<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>
<p className="mt-0.5 text-muted-foreground text-xs">
{field.value ? (
<div>
<span>
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
</div>
</span>
) : (
<Trans>Default file</Trans>
)}
@@ -475,6 +563,18 @@ 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" />
@@ -517,7 +617,7 @@ export function TemplateUseDialog({
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
form.setError(`customDocumentData.${i}.data`, {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
@@ -526,7 +626,7 @@ export function TemplateUseDialog({
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError('customDocumentData', {
form.setError(`customDocumentData.${i}.data`, {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
@@ -537,6 +637,8 @@ export function TemplateUseDialog({
}
field.onChange(file);
form.clearErrors(`customDocumentData.${i}.data`);
updateLastUploadedFile(file);
}}
/>
</div>
@@ -550,6 +652,112 @@ 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">
@@ -0,0 +1,371 @@
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';
@@ -0,0 +1,203 @@
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);
};
@@ -0,0 +1,17 @@
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>
);
};
@@ -0,0 +1,45 @@
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>
);
};
@@ -0,0 +1,214 @@
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';
};
@@ -0,0 +1,67 @@
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>
);
};
@@ -0,0 +1,37 @@
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>
);
};
@@ -0,0 +1,264 @@
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],
}));
@@ -0,0 +1,63 @@
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>
);
};
@@ -0,0 +1,198 @@
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 }));
};
@@ -0,0 +1,145 @@
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,18 +599,10 @@ 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,6 +1,9 @@
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';
@@ -22,6 +25,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
const { organisations } = useSession();
const currentTeam = useOptionalCurrentTeam();
const currentOrganisation = useOptionalCurrentOrganisation();
const { data: unreadCountData } = trpc.document.inbox.getCount.useQuery(
{
@@ -37,18 +41,19 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
};
const menuNavigationLinks = useMemo(() => {
let teamUrl = currentTeam?.url || null;
const navigationTeam =
currentTeam ??
(organisations.length === 1 && organisations[0].teams.length === 1 ? organisations[0].teams[0] : null);
if (!teamUrl && organisations.length === 1 && organisations[0].teams.length === 1) {
teamUrl = organisations[0].teams[0].url;
}
if (!teamUrl) {
if (!navigationTeam) {
return [
{
href: '/inbox',
text: t`Inbox`,
},
...(currentOrganisation && canAccessOrganisationAnalytics(currentOrganisation.currentOrganisationRole)
? [{ href: formatOrganisationAnalyticsPath(currentOrganisation.url), text: t`Analytics` }]
: []),
{
href: '/settings/profile',
text: t`Settings`,
@@ -56,6 +61,8 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
];
}
const teamUrl = navigationTeam.url;
return [
{
href: `/t/${teamUrl}/documents`,
@@ -69,12 +76,15 @@ 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, organisations]);
}, [currentTeam, currentOrganisation, organisations, t]);
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
@@ -1,66 +0,0 @@
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>
);
}
@@ -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 { useQueryState } from 'nuqs';
import { useQueryStates } from 'nuqs';
import { useEffect, useState } from 'react';
import { documentsSearchParams } from '~/utils/documents-search-params';
@@ -10,16 +10,26 @@ import { documentsSearchParams } from '~/utils/documents-search-params';
export const DocumentSearch = () => {
const { _ } = useLingui();
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
const [{ query }, setSearchParams] = useQueryStates(
{
query: documentsSearchParams.query,
page: documentsSearchParams.page,
},
{ history: 'push' },
);
const [searchTerm, setSearchTerm] = useState(query ?? '');
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm !== (query ?? '')) {
void setQuery(debouncedSearchTerm || null);
// Reset pagination so a new search never lands on an empty page.
void setSearchParams({
query: debouncedSearchTerm || null,
page: null,
});
}
}, [debouncedSearchTerm, query, setQuery]);
}, [debouncedSearchTerm, query, setSearchParams]);
return (
<Input
@@ -16,7 +16,7 @@ import {
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { isHttpUrl } from '@documenso/lib/utils/is-http-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 === '' || isValidRedirectUrl(value), {
.refine((value) => value === undefined || value === '' || isHttpUrl(value), {
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
}),
language: z
@@ -31,6 +31,8 @@ type FilterPillCommonProps = {
enableSearch?: boolean;
searchPlaceholder?: string;
loading?: boolean;
/** Whether the selection can be removed. Defaults to true. */
clearable?: boolean;
testId?: string;
};
@@ -61,7 +63,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, testId } = props;
const { icon: Icon, label, options, enableSearch, searchPlaceholder, loading, clearable = true, testId } = props;
const [open, setOpen] = useState(false);
@@ -84,7 +86,7 @@ export const FilterPill = (props: FilterPillProps) => {
return;
}
props.onChange(nextValue === props.value ? null : nextValue);
props.onChange(nextValue === props.value && clearable ? null : nextValue);
setOpen(false);
};
@@ -168,7 +170,7 @@ export const FilterPill = (props: FilterPillProps) => {
))}
</CommandGroup>
{hasSelection && (
{hasSelection && clearable && (
<>
<CommandSeparator />
<CommandGroup>
@@ -7,9 +7,10 @@ export type CardMetricProps = {
value?: string | number;
className?: string;
children?: React.ReactNode;
testId?: string;
};
export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => {
export const CardMetric = ({ icon: Icon, title, value, className, children, testId }: CardMetricProps) => {
return (
<div
className={cn(
@@ -29,7 +30,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
</div>
{children || (
<p className="mt-auto font-semibold text-4xl text-foreground leading-8">
<p className="mt-auto font-semibold text-4xl text-foreground leading-8" data-testid={testId}>
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
)}
@@ -6,9 +6,13 @@ 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 { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import {
canAccessOrganisationAnalytics,
canExecuteOrganisationAction,
formatOrganisationAnalyticsPath,
} from '@documenso/lib/utils/organisations';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { canExecuteTeamAction, formatAnalyticsPath } 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';
@@ -62,6 +66,13 @@ 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
@@ -271,6 +282,14 @@ 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={
@@ -1,37 +0,0 @@
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,4 +1,5 @@
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 } = {
@@ -14,9 +15,10 @@ export type StackAvatarProps = {
zIndex?: string;
fallbackText?: string;
type: RecipientStatusType;
className?: string;
};
export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAvatarProps) => {
export const StackAvatar = ({ first, zIndex, fallbackText = '', type, className }: StackAvatarProps) => {
let classes = '';
let zIndexClass = '';
const firstClass = first ? '' : '-ml-3';
@@ -46,7 +48,14 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
}
return (
<Avatar className={` ${zIndexClass} ${firstClass} h-10 w-10 border-2 border-white border-solid dark:border-border`}>
<Avatar
className={cn(
zIndexClass,
firstClass,
'h-10 w-10 border-2 border-white border-solid dark:border-border',
className,
)}
>
<AvatarFallback className={classes}>{fallbackText}</AvatarFallback>
</Avatar>
);
@@ -1,16 +1,33 @@
import { getRecipientType, RecipientStatusType } from '@documenso/lib/client-only/recipient-type';
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 { 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 { Trans } from '@lingui/react/macro';
import type { DocumentStatus } from '@prisma/client';
import { useMemo } from '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 { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
@@ -27,125 +44,238 @@ export const StackAvatarsWithTooltip = ({
}: StackAvatarsWithTooltipProps) => {
const { _ } = useLingui();
const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.WAITING,
);
const sections = useMemo(() => {
const groups = groupRecipientsByStatus(recipients);
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;
}),
];
return RECIPIENT_STATUS_SECTIONS.map((section) => ({
...section,
recipients: groups[section.type],
})).filter((section) => section.recipients.length > 0);
}, [recipients]);
const canCopySigningLink = documentStatus === DocumentStatus.PENDING;
return (
<PopoverHover
trigger={children || <StackAvatars recipients={sortedRecipients} />}
trigger={children || <RecipientAvatarStack recipients={recipients} />}
contentProps={{
className: 'flex flex-col gap-y-5 py-2',
className:
'max-h-[var(--radix-popover-content-available-height)] w-72 divide-y divide-border/50 overflow-y-auto p-0 text-sm',
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(),
}}
>
{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}
{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
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
recipient={recipient}
signingToken={canCopySigningLink && section.hasSigningLink ? recipient.token : null}
/>
<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>
</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,
},
];
@@ -1,41 +0,0 @@
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)}</>;
}
@@ -0,0 +1,42 @@
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"
/>
);
};
@@ -1,75 +0,0 @@
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 { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useOptionalCurrentOrganisation } 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,13 +14,26 @@ import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableStatusFilterProps = {
stats: TFindDocumentsInternalResponse['stats'];
/**
* 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[];
};
export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
export const DocumentsTableStatusFilter = ({
stats,
statuses = SELECTABLE_STATUSES,
}: DocumentsTableStatusFilterProps) => {
const { _ } = useLingui();
const organisation = useCurrentOrganisation();
const organisation = useOptionalCurrentOrganisation();
const [{ status }, setSearchParams] = useQueryStates(
{
@@ -32,14 +45,14 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter
const selectableStatuses = useMemo(
() =>
SELECTABLE_STATUSES.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
statuses.filter((value) => {
if (organisation?.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
return true;
}),
[organisation.type],
[organisation?.type, statuses],
);
const selectedStatus = useMemo(
@@ -65,20 +78,22 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter
options={selectableStatuses.map((value) => ({
value,
label: <DocumentStatus status={value} />,
trailing: formatStatsCount(stats[value]),
trailing: stats ? formatStatsCount(stats[value]) : undefined,
}))}
testId="documents-table-status-filter"
/>
{/* Visually hidden document counts, for screen readers and tests. */}
<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>
{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>
)}
</>
);
};
@@ -16,22 +16,17 @@ 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 = () => {
@@ -40,17 +35,24 @@ export const InboxTable = () => {
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const [findInboxSearchParams] = useQueryStates(inboxSearchParams, {
history: 'push',
});
const status = resolveInboxStatus(findInboxSearchParams.status);
const query = findInboxSearchParams.query ?? '';
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({
page: page || 1,
perPage: perPage || 10,
page: Math.max(findInboxSearchParams.page ?? 1, 1),
perPage: Math.min(Math.max(findInboxSearchParams.perPage ?? 10, 1), 100),
query: query || undefined,
status,
});
const hasSearchQuery = query.trim().length > 0;
const columns = useMemo(() => {
return [
{
@@ -123,7 +125,20 @@ export const InboxTable = () => {
emptyState={
<div className="flex h-60 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<p>
<Trans>Documents that require your attention will appear here</Trans>
{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>
))}
</p>
</div>
}
@@ -170,7 +170,10 @@ export const TemplatesTableActionDropdown = ({
onOpenChange={setRenameDialogOpen}
envelopeType="template"
onSuccess={async () => {
await trpcUtils.template.findTemplates.invalidate();
await Promise.all([
trpcUtils.template.findTemplates.invalidate(),
trpcUtils.template.findTemplatesInternal.invalidate(),
]);
}}
/>
</DropdownMenu>
@@ -0,0 +1,61 @@
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"
/>
);
};
@@ -0,0 +1,42 @@
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,8 +2,11 @@ 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() {
@@ -26,6 +29,14 @@ 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,7 +1,11 @@
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 { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import {
canAccessOrganisationAnalytics,
canExecuteOrganisationAction,
formatOrganisationAnalyticsPath,
} 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';
@@ -17,6 +21,7 @@ import {
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRight,
BarChart3Icon,
CalendarIcon,
MoreVerticalIcon,
PlusIcon,
@@ -114,11 +119,22 @@ export default function OrganisationSettingsTeamsPage() {
</p>
</div>
<Button asChild>
<Link to={`/o/${organisation.url}/settings/general`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
<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>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -0,0 +1,179 @@
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;
@@ -0,0 +1,183 @@
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();
};
@@ -5,26 +5,28 @@ 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 } from 'lucide-react';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
import { Bird, XIcon } from 'lucide-react';
import { useQueryStates } from 'nuqs';
import { useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'react-router';
import { useParams } 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';
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
import { templatesSearchParams } from '~/utils/templates-search-params';
export function meta() {
return appMetaTags(msg`Templates`);
@@ -39,15 +41,26 @@ export default function TemplatesPage() {
const organisation = useCurrentOrganisation();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
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 page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const isOrgView = findTemplateSearchParams.view === 'organisation';
const showOrgFilter = organisation.type !== OrganisationType.PERSONAL;
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
const hasActiveFilters = Boolean(ownerIds?.length);
const isSearchingOrFiltering = hasActiveFilters || Boolean(query);
const isOrgView = view === 'organisation';
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
const onResetFilters = () => {
void setFindTemplateSearchParams({
ownerIds: null,
page: null,
});
};
// Scoped by team so selections made in one team never leak into another.
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
@@ -64,11 +77,13 @@ export default function TemplatesPage() {
const documentRootPath = formatDocumentsPath(team.url);
const templateRootPath = formatTemplatesPath(team.url);
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
const teamTemplatesQuery = trpc.template.findTemplatesInternal.useQuery(
{
page,
perPage,
folderId,
query,
ownerIds,
},
{
enabled: !isOrgView,
@@ -79,6 +94,7 @@ export default function TemplatesPage() {
{
page,
perPage,
query,
},
{
enabled: isOrgView,
@@ -87,14 +103,6 @@ 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">
@@ -112,31 +120,25 @@ export default function TemplatesPage() {
</h1>
</div>
{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 className="mt-6 flex flex-wrap items-center gap-x-2 gap-y-4">
<div className="w-56">
<TemplateSearch />
</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 ? (
{activeQuery.data && activeQuery.data.count === 0 && !isSearchingOrFiltering ? (
<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
@@ -0,0 +1,193 @@
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 };
};
@@ -0,0 +1,33 @@
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);
};
@@ -0,0 +1,17 @@
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,
};
+1 -38
View File
@@ -1,4 +1,3 @@
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';
@@ -14,49 +13,13 @@ 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),
});
// 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();
return 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\/|^\/__/;
+1 -1
View File
@@ -17662,7 +17662,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
@@ -30844,6 +30843,7 @@
"clsx": "^1.2.1",
"cmdk": "^1.1.1",
"colord": "^2.9.3",
"date-fns": "^3.6.0",
"framer-motion": "^12.43.0",
"lucide-react": "^0.554.0",
"luxon": "^3.7.2",
@@ -0,0 +1,423 @@
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);
});
});
@@ -0,0 +1,545 @@
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,6 +167,29 @@ 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();
@@ -0,0 +1,335 @@
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/);
});
});
@@ -0,0 +1,289 @@
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);
};
@@ -0,0 +1,690 @@
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,6 +40,88 @@ 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,
@@ -156,8 +238,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.getByLabel('Name').click();
await page.getByLabel('Name').fill('name');
await page.getByRole('textbox', { name: 'Name', exact: true }).click();
await page.getByRole('textbox', { name: 'Name', exact: true }).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: Tab Visibility ──────────────────────────────────────────────────────
// ─── UI: View Filter Visibility ──────────────────────────────────────────────
test.describe('Organisation Templates - UI Tabs', () => {
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
test.describe('Organisation Templates - UI View Filter', () => {
test('should show the view filter for non-personal orgs', async ({ page }) => {
const { ownerA, teamA } = await seedOrgTemplateScenario();
await apiSignin({
@@ -110,11 +110,10 @@ test.describe('Organisation Templates - UI Tabs', () => {
redirectPath: `/t/${teamA.url}/templates`,
});
await expect(page.getByTestId('template-tab-team')).toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
});
test('should not show tabs for personal organisations', async ({ page }) => {
test('should not show the view filter for personal organisations', async ({ page }) => {
const { user, team } = await seedUser({ isPersonalOrganisation: true });
await apiSignin({
@@ -123,15 +122,14 @@ test.describe('Organisation Templates - UI Tabs', () => {
redirectPath: `/t/${team.url}/templates`,
});
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
await expect(page.getByTestId('templates-table-view-filter')).not.toBeVisible();
});
});
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
test.describe('Organisation Templates - Listing', () => {
test('should list org templates from other teams under the Organisation tab', async ({ page }) => {
test('should list org templates from other teams under the organisation view', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
@@ -140,17 +138,30 @@ test.describe('Organisation Templates - Listing', () => {
redirectPath: `/t/${teamB.url}/templates`,
});
// Team tab should show 0 (memberB has no templates on teamB).
await expect(page.getByTestId('template-tab-team')).toBeVisible();
// Team view is active by default (memberB has no templates on teamB).
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
// Switch to Organisation tab.
await page.getByTestId('template-tab-organisation').click();
// Switch to the organisation view.
await page.getByTestId('templates-table-view-filter').click();
await page.getByRole('option', { name: 'Organization' }).click();
// Should see the org template from teamA.
await expect(page.getByText(orgTemplate.title)).toBeVisible();
});
test('should not show private templates from other teams under Organisation tab', async ({ page }) => {
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 }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
// Create a private template on teamA — should NOT appear in org tab.
@@ -0,0 +1,514 @@
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
@@ -0,0 +1,27 @@
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,6 +92,7 @@ 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';
+1 -40
View File
@@ -1,44 +1,5 @@
import { rawTimeZones, timeZonesNames } from '@vvo/tzdb';
export const TIME_ZONE_DATA = rawTimeZones;
import { timeZonesNames } from '@vvo/tzdb';
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];
+4 -6
View File
@@ -1,12 +1,10 @@
import { z } from 'zod';
import { isValidRedirectUrl } from '../utils/is-valid-redirect-url';
import { isHttpUrl } from '../utils/is-http-url';
/**
* Note this allows empty strings.
*/
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.',
});
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.',
});
@@ -169,6 +169,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
const {
branding,
emailLanguage,
settings,
organisationType,
senderEmail,
replyToEmail,
@@ -225,11 +226,16 @@ 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`);
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}".`,
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}".`,
);
}
}
const customEmailTemplate = {
@@ -256,6 +262,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
selfSigner,
organisationType,
teamName: envelope.team?.name,
includeSenderDetails: settings.includeSenderDetails,
reportUrl,
});
@@ -0,0 +1,81 @@
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,
};
};
@@ -0,0 +1,117 @@
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),
},
};
};
@@ -0,0 +1,101 @@
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' }));
};
@@ -0,0 +1,69 @@
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],
};
};
@@ -0,0 +1,117 @@
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,
};
};
@@ -0,0 +1,127 @@
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,
};
}),
};
};
@@ -0,0 +1,81 @@
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,
};
};
@@ -0,0 +1,126 @@
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,
};
};
@@ -0,0 +1,127 @@
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),
},
};
};
@@ -0,0 +1,202 @@
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);
};
@@ -0,0 +1,70 @@
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],
};
};
@@ -0,0 +1,119 @@
import { prisma } from '@documenso/prisma';
import type {
TGetTeamAnalyticsTemplateUsageRequest,
TGetTeamAnalyticsTemplateUsageResponse,
} from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { EnvelopeType } from '@prisma/client';
import { mapTemplateIdToSecondaryId } from '../../utils/envelope';
import { resolveTeamAnalyticsRange } from '../../utils/team-analytics-range';
import { getTeamAnalyticsScope } from './get-team-analytics-scope';
export type GetTeamAnalyticsTemplateUsageOptions = TGetTeamAnalyticsTemplateUsageRequest & {
userId: number;
userEmail: string;
};
/**
* Templates ranked by how many visible documents were created from them inside the
* requested window. Template metadata is null when the template has been deleted or
* is not visible to the caller.
*/
export const getTeamAnalyticsTemplateUsage = async ({
userId,
userEmail,
teamId,
range,
from,
to,
timezone,
limit,
}: GetTeamAnalyticsTemplateUsageOptions): Promise<TGetTeamAnalyticsTemplateUsageResponse> => {
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: ['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,
teamId: scope.teamId,
deletedAt: null,
secondaryId: {
in: usage.map(({ templateId }) => mapTemplateIdToSecondaryId(templateId)),
},
OR: [{ visibility: { in: scope.allowedVisibilities } }, { userId }],
},
select: {
id: true,
secondaryId: true,
title: true,
updatedAt: 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,
count,
};
}),
};
};
@@ -0,0 +1,34 @@
import type { Prisma } from '@prisma/client';
/**
* Builds the search clause for template listings.
*
* Matches the same fields as the documents search so both pages behave the
* same way: title, external ID, and recipient name or email.
*
* Returns `null` when the query is empty so callers can skip the clause.
*/
export const buildTemplateSearchFilter = (query?: string): Prisma.EnvelopeWhereInput | null => {
const searchQuery = query?.trim() ?? '';
if (searchQuery.length === 0) {
return null;
}
return {
OR: [
{ title: { contains: searchQuery, mode: 'insensitive' } },
{ externalId: { contains: searchQuery, mode: 'insensitive' } },
{
recipients: {
some: {
OR: [
{ name: { contains: searchQuery, mode: 'insensitive' } },
{ email: { contains: searchQuery, mode: 'insensitive' } },
],
},
},
},
],
};
};
@@ -5,12 +5,14 @@ import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { FindResultResponse } from '../../types/search-params';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
import { buildTemplateSearchFilter } from './build-template-search-filter';
export type FindOrganisationTemplatesOptions = {
userId: number;
teamId: number;
page?: number;
perPage?: number;
query?: string;
};
export const findOrganisationTemplates = async ({
@@ -18,6 +20,7 @@ export const findOrganisationTemplates = async ({
teamId,
page = 1,
perPage = 10,
query,
}: FindOrganisationTemplatesOptions) => {
const [team, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
@@ -30,6 +33,8 @@ export const findOrganisationTemplates = async ({
}),
]);
const searchFilter = buildTemplateSearchFilter(query);
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
@@ -39,6 +44,7 @@ export const findOrganisationTemplates = async ({
team: {
organisationId: team.organisationId,
},
AND: searchFilter ? [searchFilter] : undefined,
};
const templateInclude = {
@@ -5,6 +5,7 @@ import { EnvelopeType, type Prisma } from '@prisma/client';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { FindResultResponse } from '../../types/search-params';
import { getMemberRoles } from '../team/get-member-roles';
import { buildTemplateSearchFilter } from './build-template-search-filter';
export type FindTemplatesOptions = {
userId: number;
@@ -13,6 +14,8 @@ export type FindTemplatesOptions = {
page?: number;
perPage?: number;
folderId?: string;
query?: string;
ownerIds?: number[];
};
export const findTemplates = async ({
@@ -22,6 +25,8 @@ export const findTemplates = async ({
page = 1,
perPage = 10,
folderId,
query,
ownerIds,
}: FindTemplatesOptions) => {
const { teamRole } = await getMemberRoles({
teamId,
@@ -31,23 +36,35 @@ export const findTemplates = async ({
},
});
const filters: Prisma.EnvelopeWhereInput[] = [
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
},
{ userId, teamId },
],
},
folderId ? { folderId } : { folderId: null },
];
if (ownerIds && ownerIds.length > 0) {
filters.push({ userId: { in: ownerIds } });
}
const searchFilter = buildTemplateSearchFilter(query);
if (searchFilter) {
filters.push(searchFilter);
}
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: type,
AND: [
{ teamId },
{
OR: [
{
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
},
},
{ userId, teamId },
],
},
folderId ? { folderId } : { folderId: null },
],
AND: filters,
};
const templateInclude = {
+2 -2
View File
@@ -2,7 +2,7 @@ import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats'
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { isHttpUrl } from '@documenso/lib/utils/is-http-url';
import { zEmail } from '@documenso/lib/utils/zod';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import { msg } from '@lingui/core/macro';
@@ -71,7 +71,7 @@ export type TDocumentMetaDateFormat = z.infer<typeof ZDocumentMetaDateFormatSche
export const ZDocumentMetaRedirectUrlSchema = z
.string()
.describe('The URL to which the recipient should be redirected after signing the document.')
.refine((value) => value === undefined || value === '' || isValidRedirectUrl(value), {
.refine((value) => value === undefined || value === '' || isHttpUrl(value), {
message: 'Please enter a valid URL, make sure you include http:// or https:// part of the url.',
});
@@ -1,15 +0,0 @@
const ALLOWED_PROTOCOLS = ['http', 'https'];
export const isValidRedirectUrl = (value: string) => {
try {
const url = new URL(value);
if (!ALLOWED_PROTOCOLS.includes(url.protocol.slice(0, -1).toLowerCase())) {
return false;
}
return true;
} catch {
return false;
}
};
+13 -1
View File
@@ -1,6 +1,6 @@
import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import type { Organisation, OrganisationGlobalSettings, Prisma } from '@prisma/client';
import { DocumentVisibility, type OrganisationGroup, type OrganisationMemberRole } from '@prisma/client';
import { DocumentVisibility, type OrganisationGroup, OrganisationMemberRole } from '@prisma/client';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration';
@@ -30,6 +30,18 @@ export const canExecuteOrganisationAction = (
return ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP[action].some((i) => i === role);
};
/**
* Organisation analytics are restricted to organisation admins, unlike organisation
* settings which managers can also access.
*/
export const canAccessOrganisationAnalytics = (role: keyof typeof ORGANISATION_MEMBER_ROLE_MAP) => {
return role === OrganisationMemberRole.ADMIN;
};
export const formatOrganisationAnalyticsPath = (organisationUrl: string) => {
return `/o/${organisationUrl}/analytics`;
};
/**
* Compares the provided `currentUserRole` with the provided `roleToCheck` to determine
* whether the `currentUserRole` has permission to modify the `roleToCheck`.
@@ -8,15 +8,15 @@ describe('renderCustomEmailTemplate', () => {
});
it('replaces multiple variables separated by whitespace', () => {
expect(
renderCustomEmailTemplate('Hi {name}, sign at {url}', { name: 'Sam', url: 'https://x' }),
).toBe('Hi Sam, sign at https://x');
expect(renderCustomEmailTemplate('Hi {name}, sign at {url}', { name: 'Sam', url: 'https://x' })).toBe(
'Hi Sam, sign at https://x',
);
});
it('replaces adjacent variables and variables separated by a non-whitespace character', () => {
expect(
renderCustomEmailTemplate('{day}/{month}/{year}', { day: '01', month: '02', year: '2026' }),
).toBe('01/02/2026');
expect(renderCustomEmailTemplate('{day}/{month}/{year}', { day: '01', month: '02', year: '2026' })).toBe(
'01/02/2026',
);
expect(
renderCustomEmailTemplate('{firstName}-{lastName}', {
@@ -0,0 +1,323 @@
import { describe, expect, it } from 'vitest';
import { AppErrorCode } from '../errors/app-error';
import { resolveTeamAnalyticsRange } from './team-analytics-range';
const invalidRequest = expect.objectContaining({ code: AppErrorCode.INVALID_REQUEST });
describe('resolveTeamAnalyticsRange', () => {
describe('presets', () => {
it('ends at the start of tomorrow so today is included', () => {
const resolved = resolveTeamAnalyticsRange({
range: '7d',
timezone: 'UTC',
now: new Date('2025-01-15T12:34:56.000Z'),
});
expect(resolved).toEqual({
range: '7d',
from: '2025-01-09',
to: '2025-01-15',
timezone: 'UTC',
start: new Date('2025-01-09T00:00:00.000Z'),
end: new Date('2025-01-16T00:00:00.000Z'),
previousStart: new Date('2025-01-02T00:00:00.000Z'),
previousEnd: new Date('2025-01-09T00:00:00.000Z'),
bucket: 'day',
});
});
it('keeps boundaries on local midnight across the March DST change in America/New_York', () => {
// DST began on 2024-03-10 in New York (EST -05:00 -> EDT -04:00).
const resolved = resolveTeamAnalyticsRange({
range: '7d',
timezone: 'America/New_York',
now: new Date('2024-03-12T18:00:00.000Z'),
});
// 2024-03-13T00:00 EDT
expect(resolved.end).toEqual(new Date('2024-03-13T04:00:00.000Z'));
// 2024-03-06T00:00 EST (before the change), still local midnight.
expect(resolved.start).toEqual(new Date('2024-03-06T05:00:00.000Z'));
// 2024-02-28T00:00 EST
expect(resolved.previousStart).toEqual(new Date('2024-02-28T05:00:00.000Z'));
});
it('resolves 30d and 90d as calendar-day windows', () => {
const now = new Date('2025-06-30T23:59:59.000Z');
const thirty = resolveTeamAnalyticsRange({ range: '30d', timezone: 'UTC', now });
expect(thirty.end).toEqual(new Date('2025-07-01T00:00:00.000Z'));
expect(thirty.start).toEqual(new Date('2025-06-01T00:00:00.000Z'));
expect(thirty.previousStart).toEqual(new Date('2025-05-02T00:00:00.000Z'));
expect(thirty.previousEnd).toEqual(thirty.start);
expect(thirty.from).toBe('2025-06-01');
expect(thirty.to).toBe('2025-06-30');
const ninety = resolveTeamAnalyticsRange({ range: '90d', timezone: 'UTC', now });
expect(ninety.end).toEqual(new Date('2025-07-01T00:00:00.000Z'));
expect(ninety.start).toEqual(new Date('2025-04-02T00:00:00.000Z'));
expect(ninety.previousStart).toEqual(new Date('2025-01-02T00:00:00.000Z'));
expect(ninety.from).toBe('2025-04-02');
expect(ninety.to).toBe('2025-06-30');
});
it('aligns 12m to the start of the month 11 months ago with monthly buckets', () => {
const resolved = resolveTeamAnalyticsRange({
range: '12m',
timezone: 'UTC',
now: new Date('2025-03-20T10:00:00.000Z'),
});
expect(resolved).toEqual({
range: '12m',
from: '2024-04-01',
to: '2025-03-20',
timezone: 'UTC',
start: new Date('2024-04-01T00:00:00.000Z'),
end: new Date('2025-03-21T00:00:00.000Z'),
previousStart: new Date('2023-04-01T00:00:00.000Z'),
previousEnd: new Date('2024-04-01T00:00:00.000Z'),
bucket: 'month',
});
});
it('aligns 12m month boundaries to the request timezone', () => {
// 2025-01-01T03:00Z is still 2024-12-31 in Los Angeles.
const resolved = resolveTeamAnalyticsRange({
range: '12m',
timezone: 'America/Los_Angeles',
now: new Date('2025-01-01T03:00:00.000Z'),
});
// 2024-01-01T00:00 PST
expect(resolved.start).toEqual(new Date('2024-01-01T08:00:00.000Z'));
// 2025-01-01T00:00 PST
expect(resolved.end).toEqual(new Date('2025-01-01T08:00:00.000Z'));
// 2023-01-01T00:00 PST
expect(resolved.previousStart).toEqual(new Date('2023-01-01T08:00:00.000Z'));
expect(resolved.from).toBe('2024-01-01');
expect(resolved.to).toBe('2024-12-31');
});
it('ignores from/to for presets', () => {
const resolved = resolveTeamAnalyticsRange({
range: '7d',
from: '2020-01-01',
to: '2020-01-02',
timezone: 'UTC',
now: new Date('2025-01-15T12:34:56.000Z'),
});
expect(resolved.from).toBe('2025-01-09');
expect(resolved.to).toBe('2025-01-15');
});
it('rejects invalid timezones', () => {
expect(() =>
resolveTeamAnalyticsRange({
range: '30d',
timezone: 'Not/A_Zone',
now: new Date('2025-01-15T12:00:00.000Z'),
}),
).toThrow(invalidRequest);
});
});
describe('custom', () => {
const now = new Date('2025-01-15T12:00:00.000Z');
it('resolves an inclusive calendar window with an equally sized previous window', () => {
const resolved = resolveTeamAnalyticsRange({
range: 'custom',
from: '2024-02-01',
to: '2024-02-29',
timezone: 'UTC',
now,
});
// 29 days (leap February), previous window is the 29 days ending Jan 31.
expect(resolved).toEqual({
range: 'custom',
from: '2024-02-01',
to: '2024-02-29',
timezone: 'UTC',
start: new Date('2024-02-01T00:00:00.000Z'),
end: new Date('2024-03-01T00:00:00.000Z'),
previousStart: new Date('2024-01-03T00:00:00.000Z'),
previousEnd: new Date('2024-02-01T00:00:00.000Z'),
bucket: 'day',
});
});
it('uses day buckets up to 92 days and month buckets beyond', () => {
// Apr 1 .. Jul 1 2024 inclusive is 92 days.
const ninetyTwo = resolveTeamAnalyticsRange({
range: 'custom',
from: '2024-04-01',
to: '2024-07-01',
timezone: 'UTC',
now,
});
expect(ninetyTwo.bucket).toBe('day');
const ninetyThree = resolveTeamAnalyticsRange({
range: 'custom',
from: '2024-04-01',
to: '2024-07-02',
timezone: 'UTC',
now,
});
expect(ninetyThree.bucket).toBe('month');
expect(ninetyThree.start).toEqual(new Date('2024-04-01T00:00:00.000Z'));
expect(ninetyThree.end).toEqual(new Date('2024-07-03T00:00:00.000Z'));
expect(ninetyThree.previousStart).toEqual(new Date('2023-12-30T00:00:00.000Z'));
});
it('allows a single-day range and a range ending today', () => {
const resolved = resolveTeamAnalyticsRange({
range: 'custom',
from: '2025-01-15',
to: '2025-01-15',
timezone: 'UTC',
now,
});
expect(resolved.start).toEqual(new Date('2025-01-15T00:00:00.000Z'));
expect(resolved.end).toEqual(new Date('2025-01-16T00:00:00.000Z'));
expect(resolved.previousStart).toEqual(new Date('2025-01-14T00:00:00.000Z'));
});
it('keeps local midnight bounds across the March DST change in America/New_York', () => {
// DST began on 2024-03-10 in New York (EST -05:00 -> EDT -04:00).
const resolved = resolveTeamAnalyticsRange({
range: 'custom',
from: '2024-03-06',
to: '2024-03-12',
timezone: 'America/New_York',
now,
});
// 2024-03-06T00:00 EST
expect(resolved.start).toEqual(new Date('2024-03-06T05:00:00.000Z'));
// 2024-03-13T00:00 EDT
expect(resolved.end).toEqual(new Date('2024-03-13T04:00:00.000Z'));
// 7 calendar days, not 7 * 24h: 2024-02-28T00:00 EST
expect(resolved.previousStart).toEqual(new Date('2024-02-28T05:00:00.000Z'));
expect(resolved.from).toBe('2024-03-06');
expect(resolved.to).toBe('2024-03-12');
expect(resolved.bucket).toBe('day');
});
it('evaluates "today" in the request timezone', () => {
// 2025-01-15T03:00Z is still 2025-01-14 in Los Angeles, so 2025-01-15 is in the future there.
const lateNow = new Date('2025-01-15T03:00:00.000Z');
expect(() =>
resolveTeamAnalyticsRange({
range: 'custom',
from: '2025-01-10',
to: '2025-01-15',
timezone: 'America/Los_Angeles',
now: lateNow,
}),
).toThrow(invalidRequest);
expect(() =>
resolveTeamAnalyticsRange({
range: 'custom',
from: '2025-01-10',
to: '2025-01-15',
timezone: 'UTC',
now: lateNow,
}),
).not.toThrow();
});
it('rejects from after to', () => {
expect(() =>
resolveTeamAnalyticsRange({
range: 'custom',
from: '2024-02-10',
to: '2024-02-01',
timezone: 'UTC',
now,
}),
).toThrow(invalidRequest);
});
it('rejects a missing bound', () => {
expect(() =>
resolveTeamAnalyticsRange({
range: 'custom',
from: '2024-02-01',
timezone: 'UTC',
now,
}),
).toThrow(invalidRequest);
expect(() =>
resolveTeamAnalyticsRange({
range: 'custom',
to: '2024-02-01',
timezone: 'UTC',
now,
}),
).toThrow(invalidRequest);
});
it('rejects a to in the future', () => {
expect(() =>
resolveTeamAnalyticsRange({
range: 'custom',
from: '2025-01-01',
to: '2025-01-16',
timezone: 'UTC',
now,
}),
).toThrow(invalidRequest);
});
it('rejects ranges starting more than a year and a day ago', () => {
// now is 2025-01-15, so the earliest allowed start is 2024-01-14.
expect(() =>
resolveTeamAnalyticsRange({
range: 'custom',
from: '2024-01-13',
to: '2024-03-10',
timezone: 'UTC',
now,
}),
).toThrow(invalidRequest);
// Exactly a year and a day ago is allowed, up to today.
expect(() =>
resolveTeamAnalyticsRange({
range: 'custom',
from: '2024-01-14',
to: '2025-01-15',
timezone: 'UTC',
now,
}),
).not.toThrow();
});
it('rejects malformed or non-existent dates', () => {
for (const to of ['2024-02-30', '2024-13-01', '2024-02-1', '2024-02-01T00:00:00Z', 'yesterday']) {
expect(() =>
resolveTeamAnalyticsRange({
range: 'custom',
from: '2024-06-01',
to,
timezone: 'UTC',
now,
}),
).toThrow(invalidRequest);
}
});
});
});
+174
View File
@@ -0,0 +1,174 @@
import type {
TTeamAnalyticsRange,
TTeamAnalyticsResolvedRange,
} from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { DateTime, IANAZone } from 'luxon';
import { AppError, AppErrorCode } from '../errors/app-error';
const DAY_RANGE_LENGTHS: Record<Exclude<TTeamAnalyticsRange, '12m' | 'custom'>, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
};
/** Custom ranges longer than this many days are bucketed by month instead of by day. */
const CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS = 92;
const DATE_FORMAT = 'yyyy-MM-dd';
export type ResolveTeamAnalyticsRangeOptions = {
range: TTeamAnalyticsRange;
/** Inclusive start date (yyyy-MM-dd) in `timezone`; required when `range` is `custom`. */
from?: string;
/** Inclusive end date (yyyy-MM-dd) in `timezone`; required when `range` is `custom`. */
to?: string;
timezone: string;
now?: Date;
};
/**
* Resolve an analytics range into a half-open [start, end) window in the given IANA
* timezone, plus the equally sized window immediately preceding it.
*
* For presets `end` is always the start of tomorrow in the timezone so that today is
* included. For `custom` the window is [from, to] inclusive as calendar days.
*/
export const resolveTeamAnalyticsRange = ({
range,
from,
to,
timezone,
now = new Date(),
}: ResolveTeamAnalyticsRangeOptions): TTeamAnalyticsResolvedRange => {
if (!IANAZone.isValidZone(timezone)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid analytics timezone',
});
}
const currentTime = DateTime.fromJSDate(now, { zone: timezone });
if (!currentTime.isValid) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid analytics reference time',
});
}
const today = currentTime.startOf('day');
if (range === 'custom') {
return resolveCustomRange({ from, to, timezone, today });
}
const end = today.plus({ days: 1 });
if (range === '12m') {
const start = today.startOf('month').minus({ months: 11 });
return {
range,
from: start.toFormat(DATE_FORMAT),
to: today.toFormat(DATE_FORMAT),
timezone,
start: start.toJSDate(),
end: end.toJSDate(),
previousStart: start.minus({ months: 12 }).toJSDate(),
previousEnd: start.toJSDate(),
bucket: 'month',
};
}
const days = DAY_RANGE_LENGTHS[range];
const start = end.minus({ days });
const previousStart = start.minus({ days });
return {
range,
from: start.toFormat(DATE_FORMAT),
to: today.toFormat(DATE_FORMAT),
timezone,
start: start.toJSDate(),
end: end.toJSDate(),
previousStart: previousStart.toJSDate(),
previousEnd: start.toJSDate(),
bucket: 'day',
};
};
const resolveCustomRange = ({
from,
to,
timezone,
today,
}: {
from?: string;
to?: string;
timezone: string;
today: DateTime;
}): TTeamAnalyticsResolvedRange => {
if (!from || !to) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Custom analytics range requires both "from" and "to" dates',
});
}
const fromDate = parseCalendarDate(from, timezone, 'from');
const toDate = parseCalendarDate(to, timezone, 'to');
if (fromDate > toDate) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Custom analytics range "from" must not be after "to"',
});
}
if (toDate > today) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Custom analytics range "to" must not be in the future',
});
}
const earliestFrom = today.minus(ANALYTICS_CUSTOM_RANGE_MAX_LOOKBACK);
if (fromDate < earliestFrom) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Custom analytics range must start within the last year',
});
}
const start = fromDate;
const end = toDate.plus({ days: 1 });
// Count calendar days rather than elapsed time so DST transitions do not skew the span.
const spanDays = Math.round(end.diff(start, 'days').days);
const previousStart = start.minus({ days: spanDays });
return {
range: 'custom',
from: start.toFormat(DATE_FORMAT),
to: toDate.toFormat(DATE_FORMAT),
timezone,
start: start.toJSDate(),
end: end.toJSDate(),
previousStart: previousStart.toJSDate(),
previousEnd: start.toJSDate(),
bucket: spanDays > CUSTOM_RANGE_MONTH_BUCKET_THRESHOLD_DAYS ? 'month' : 'day',
};
};
/** Parse a strict yyyy-MM-dd calendar date at local midnight in `timezone`. */
const parseCalendarDate = (value: string, timezone: string, field: 'from' | 'to'): DateTime => {
const parsed = DateTime.fromISO(value, { zone: timezone });
// Round-trip guard: rejects non-date ISO strings (e.g. datetimes) and overflowing
// dates such as 2023-02-30 that luxon would otherwise flag as invalid anyway.
if (!parsed.isValid || parsed.toISODate() !== value) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Invalid custom analytics range "${field}" date, expected yyyy-MM-dd`,
});
}
return parsed.startOf('day');
};
+4
View File
@@ -33,6 +33,10 @@ export const formatTemplatesPath = (teamUrl: string) => {
return `/t/${teamUrl}/templates`;
};
export const formatAnalyticsPath = (teamUrl: string) => {
return `/t/${teamUrl}/analytics`;
};
/**
* Determines whether a team member can execute a given action.
*
+42 -1
View File
@@ -1,8 +1,10 @@
import type { Envelope, Recipient } from '@prisma/client';
import type { DocumentMeta, Envelope, Field, Recipient } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import type { TTemplateLite } from '../types/template';
import { mapSecondaryIdToTemplateId } from './envelope';
import { mapFieldToLegacyField } from './fields';
import { mapRecipientToLegacyRecipient } from './recipients';
export const formatDirectTemplatePath = (token: string) => {
return `${NEXT_PUBLIC_WEBAPP_URL()}/d/${token}`;
@@ -67,3 +69,42 @@ export const mapEnvelopeToTemplateLite = (envelope: Envelope): TTemplateLite =>
templateDocumentDataId: '',
};
};
type EnvelopeWithTemplateManyRelations = Envelope & {
team: { id: number; url: string; name: string } | null;
fields: Field[];
recipients: Recipient[];
documentMeta: DocumentMeta | null;
directLink: { token: string; enabled: boolean } | null;
};
/**
* Maps an envelope (with the relations loaded by the template find functions)
* to the legacy "template many" response shape.
*/
export const mapEnvelopeToTemplateMany = (envelope: EnvelopeWithTemplateManyRelations) => {
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
return {
id: legacyTemplateId,
envelopeId: envelope.id,
type: envelope.templateType,
visibility: envelope.visibility,
externalId: envelope.externalId,
title: envelope.title,
userId: envelope.userId,
teamId: envelope.teamId,
authOptions: envelope.authOptions,
createdAt: envelope.createdAt,
updatedAt: envelope.updatedAt,
publicTitle: envelope.publicTitle,
publicDescription: envelope.publicDescription,
folderId: envelope.folderId,
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
team: envelope.team,
fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
templateMeta: envelope.documentMeta,
directLink: envelope.directLink,
};
};
-25
View File
@@ -1,25 +0,0 @@
import type { PrismaClient } from '@prisma/client';
export function addPrismaMiddleware(prisma: PrismaClient) {
prisma.$use(async (params, next) => {
// Check if we're creating a new team
if (params.model === 'Team' && params.action === 'create') {
// Execute the team creation
const result = await next(params);
// Create the TeamGlobalSettings
await prisma.teamGlobalSettings.create({
data: {
teamId: result.id,
},
});
return result;
}
// For all other operations, just pass through
return next(params);
});
return prisma;
}
+941
View File
@@ -0,0 +1,941 @@
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
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 { createTeamMembers } from '@documenso/trpc/server/team-router/create-team-members';
import { nanoid } from 'nanoid';
import { prisma } from '..';
import type { User } from '../client';
import {
DocumentStatus,
DocumentVisibility,
EnvelopeType,
OrganisationGroupType,
OrganisationMemberRole,
ReadStatus,
SendStatus,
SigningStatus,
TeamMemberRole,
} from '../client';
import { seedBlankDocument } from './documents';
import { seedBlankTemplate } from './templates';
/**
* One-off seed script: creates three teams with analytics-friendly data inside
* the organisation owned by `admin@documenso.com` (created by `initial-seed.ts`).
*
* Run via:
* npm run with:env -- tsx packages/prisma/seed/analytics-seed.ts
*
* Produces (idempotent: an existing team with the same URL is deleted and recreated):
* - analytics-quiet "Quiet Team" 2 members, 3 documents, no templates
* - analytics-steady "Steady Team" 4 members, ~25 documents over 90 days, 2 templates
* - analytics-busy "Busy Team" 8 members, ~180 documents over 12 months, 5 templates
*
* Definitions used by the analytics dashboard:
* - "sent" = a DOCUMENT_SENT audit log (one per non-DRAFT document)
* - "created" = Envelope.createdAt
* - "created from template" = Envelope.templateId (numeric template id)
* - "members" = organisation members attached to the team's role groups
*/
const ADMIN_EMAIL = 'admin@documenso.com';
const ADMIN_PASSWORD = 'password';
const MEMBER_EMAIL_DOMAIN = 'test.documenso.com';
const WEBAPP_URL = process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:49000';
const DAY_MS = 24 * 60 * 60 * 1000;
const HOUR_MS = 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
const NOW = new Date();
// ---------------------------------------------------------------------------
// Deterministic pseudo random (LCG) so re-runs produce the same shape.
// ---------------------------------------------------------------------------
let seedState = 20260922;
const resetRandom = (seed: number) => {
seedState = seed;
};
const rand = () => {
seedState = (seedState * 1103515245 + 12345) & 0x7fffffff;
return seedState / 0x7fffffff;
};
const randInt = (minInclusive: number, maxInclusive: number) =>
minInclusive + Math.floor(rand() * (maxInclusive - minInclusive + 1));
const pick = <T>(items: readonly T[]): T => items[Math.floor(rand() * items.length)];
const shuffle = <T>(items: T[]): T[] => {
const result = [...items];
for (let i = result.length - 1; i > 0; i -= 1) {
const j = Math.floor(rand() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
};
const pickWeightedIndex = (weights: number[]): number => {
const total = weights.reduce((sum, weight) => sum + weight, 0);
let cursor = rand() * total;
for (let i = 0; i < weights.length; i += 1) {
cursor -= weights[i];
if (cursor <= 0) {
return i;
}
}
return weights.length - 1;
};
// ---------------------------------------------------------------------------
// Time helpers
// ---------------------------------------------------------------------------
const notInFuture = (date: Date) => (date.getTime() > NOW.getTime() ? new Date(NOW.getTime() - MINUTE_MS) : date);
/**
* A timestamp `daysAgo` days back, at a random working hour (09:00-17:59 local).
*/
const atDaysAgo = (daysAgo: number) => {
const date = new Date(NOW.getTime() - daysAgo * DAY_MS);
date.setHours(randInt(9, 17), randInt(0, 59), randInt(0, 59), 0);
return notInFuture(date);
};
const isWeekend = (daysAgo: number) => {
const day = new Date(NOW.getTime() - daysAgo * DAY_MS).getDay();
return day === 0 || day === 6;
};
// ---------------------------------------------------------------------------
// Static content
// ---------------------------------------------------------------------------
const DOCUMENT_TITLES = [
'Master Services Agreement - Northwind',
'NDA - Contoso Partnership',
'Employment Offer - J. Alvarez',
'SOW #14 - Platform Migration',
'Vendor Agreement - Acme Logistics',
'Lease Renewal - 12 Harbour St',
'Consulting Agreement - Q3',
'Data Processing Addendum - Fabrikam',
'Contractor Agreement - M. Chen',
'Purchase Order 2026-0917',
'Reseller Agreement - Globex',
'Board Resolution - September',
'Equity Grant - S. Patel',
'Sponsorship Agreement - DevConf',
'Freelance Contract - Design Sprint',
'Insurance Certificate - Fleet',
'IP Assignment - Project Atlas',
'Subscription Renewal - Initech',
'Change Order #3 - Warehouse Fitout',
'Referral Agreement - Umbrella Corp',
];
const RECIPIENT_NAMES = [
'Ava Thompson',
'Liam Okafor',
'Sofia Martinez',
'Noah Kimura',
'Isabella Rossi',
'Ethan Brooks',
'Mia Johansson',
'Lucas Ferreira',
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type MemberSpec = {
name: string;
role: TeamMemberRole;
};
type TemplateSpec = {
title: string;
usage: number;
};
type DocumentSpec = {
daysAgo: number;
status: DocumentStatus;
senderIndex: number;
visibility: DocumentVisibility;
templateIndex?: number;
};
type TeamSpec = {
url: string;
name: string;
members: MemberSpec[];
templates: TemplateSpec[];
buildDocuments: () => DocumentSpec[];
};
type SeededTeamSummary = {
url: string;
name: string;
memberCount: number;
templateCount: number;
documentsByStatus: Record<string, number>;
documentsByVisibility: Record<string, number>;
documentsFromTemplates: number;
};
// ---------------------------------------------------------------------------
// Document spec builders
// ---------------------------------------------------------------------------
/**
* Builds an exact status pool from ratios, then shuffles it so the statuses are
* spread across the timeline rather than clustered.
*/
const buildStatusPool = (total: number, ratios: Partial<Record<DocumentStatus, number>>): DocumentStatus[] => {
const entries = Object.entries(ratios) as [DocumentStatus, number][];
const pool: DocumentStatus[] = [];
for (const [status, ratio] of entries) {
const count = Math.round(total * ratio);
for (let i = 0; i < count; i += 1) {
pool.push(status);
}
}
// Round-off correction so the pool has exactly `total` entries.
while (pool.length < total) {
pool.push(DocumentStatus.COMPLETED);
}
while (pool.length > total) {
pool.pop();
}
return shuffle(pool);
};
/**
* Assigns template indexes to documents that are not drafts. Templates are
* assigned in usage order so the ranking in the dashboard is stable.
*/
const assignTemplates = (specs: DocumentSpec[], templates: TemplateSpec[]) => {
const candidates = shuffle(
specs.map((_spec, index) => index).filter((index) => specs[index].status !== DocumentStatus.DRAFT),
);
let cursor = 0;
templates.forEach((template, templateIndex) => {
for (let i = 0; i < template.usage; i += 1) {
const specIndex = candidates[cursor];
if (specIndex === undefined) {
throw new Error(`Not enough documents to satisfy template usage for "${template.title}"`);
}
specs[specIndex].templateIndex = templateIndex;
cursor += 1;
}
});
};
/**
* Assigns restricted visibilities to documents sent by the admin (sender index 0),
* so managers cannot see them through the "owner" escape hatch.
*/
const assignVisibilities = (
specs: DocumentSpec[],
counts: { admin: number; managerAndAbove: number },
adminSenderIndex: number,
) => {
const candidates = shuffle(specs.map((_spec, index) => index));
let assigned = { admin: 0, managerAndAbove: 0 };
for (const specIndex of candidates) {
if (assigned.admin >= counts.admin && assigned.managerAndAbove >= counts.managerAndAbove) {
break;
}
const spec = specs[specIndex];
if (assigned.admin < counts.admin) {
spec.visibility = DocumentVisibility.ADMIN;
spec.senderIndex = adminSenderIndex;
assigned = { ...assigned, admin: assigned.admin + 1 };
continue;
}
spec.visibility = DocumentVisibility.MANAGER_AND_ABOVE;
spec.senderIndex = adminSenderIndex;
assigned = { ...assigned, managerAndAbove: assigned.managerAndAbove + 1 };
}
};
const buildQuietDocuments = (): DocumentSpec[] => [
{ daysAgo: 20, status: DocumentStatus.DRAFT, senderIndex: 0, visibility: DocumentVisibility.EVERYONE },
{ daysAgo: 45, status: DocumentStatus.DRAFT, senderIndex: 1, visibility: DocumentVisibility.EVERYONE },
{ daysAgo: 8, status: DocumentStatus.PENDING, senderIndex: 0, visibility: DocumentVisibility.EVERYONE },
];
const STEADY_TEMPLATES: TemplateSpec[] = [
{ title: 'Mutual NDA', usage: 4 },
{ title: 'Contractor Agreement', usage: 2 },
];
const buildSteadyDocuments = (): DocumentSpec[] => {
// ~10 in the last 30 days, ~8 in days 30-59, ~7 in days 60-89 => 25 total.
const dayBuckets: [number, number, number][] = [
[0, 29, 10],
[30, 59, 8],
[60, 89, 7],
];
const daysAgoList: number[] = [];
for (const [from, to, count] of dayBuckets) {
for (let i = 0; i < count; i += 1) {
daysAgoList.push(randInt(from, to));
}
}
const statuses = buildStatusPool(daysAgoList.length, {
[DocumentStatus.COMPLETED]: 0.6,
[DocumentStatus.PENDING]: 0.2,
[DocumentStatus.DRAFT]: 0.1,
[DocumentStatus.REJECTED]: 0.05,
[DocumentStatus.CANCELLED]: 0.05,
});
// Senders: admin (0), manager (1) and one member (2).
const specs: DocumentSpec[] = daysAgoList.map((daysAgo, index) => ({
daysAgo,
status: statuses[index],
senderIndex: index % 3,
visibility: DocumentVisibility.EVERYONE,
}));
assignTemplates(specs, STEADY_TEMPLATES);
assignVisibilities(specs, { admin: 2, managerAndAbove: 2 }, 0);
return specs;
};
const BUSY_TEMPLATES: TemplateSpec[] = [
{ title: 'Mutual NDA', usage: 40 },
{ title: 'Sales Order Form', usage: 25 },
{ title: 'Contractor Agreement', usage: 15 },
{ title: 'Offer Letter', usage: 8 },
{ title: 'Board Consent', usage: 3 },
];
const BUSY_DOCUMENT_COUNT = 180;
const BUSY_WINDOW_DAYS = 365;
const buildBusyDocuments = (): DocumentSpec[] => {
// Weight each day so recent weekdays are far more likely than old weekend days.
const weights = Array.from({ length: BUSY_WINDOW_DAYS }, (_, daysAgo) => {
const recency = 1 - daysAgo / BUSY_WINDOW_DAYS;
const trend = 0.3 + 1.7 * recency;
const weekdayFactor = isWeekend(daysAgo) ? 0.2 : 1;
return trend * weekdayFactor;
});
const daysAgoList = Array.from({ length: BUSY_DOCUMENT_COUNT }, () => pickWeightedIndex(weights));
const statuses = buildStatusPool(daysAgoList.length, {
[DocumentStatus.COMPLETED]: 0.7,
[DocumentStatus.PENDING]: 0.15,
[DocumentStatus.DRAFT]: 0.08,
[DocumentStatus.REJECTED]: 0.04,
[DocumentStatus.CANCELLED]: 0.03,
});
// Six senders out of eight members, with uneven volume.
const senderWeights = [3, 5, 4, 2, 3, 1];
const specs: DocumentSpec[] = daysAgoList.map((daysAgo, index) => ({
daysAgo,
status: statuses[index],
senderIndex: pickWeightedIndex(senderWeights),
visibility: DocumentVisibility.EVERYONE,
}));
assignTemplates(specs, BUSY_TEMPLATES);
assignVisibilities(specs, { admin: 6, managerAndAbove: 0 }, 0);
return specs;
};
// ---------------------------------------------------------------------------
// Team specs
// ---------------------------------------------------------------------------
const TEAM_SPECS: TeamSpec[] = [
{
url: 'analytics-quiet',
name: 'Quiet Team',
members: [{ name: 'Harper Quinn', role: TeamMemberRole.MEMBER }],
templates: [],
buildDocuments: buildQuietDocuments,
},
{
url: 'analytics-steady',
name: 'Steady Team',
members: [
{ name: 'Marcus Lindqvist', role: TeamMemberRole.MANAGER },
{ name: 'Elena Rossi', role: TeamMemberRole.MEMBER },
{ name: 'Priya Natarajan', role: TeamMemberRole.MEMBER },
],
templates: STEADY_TEMPLATES,
buildDocuments: buildSteadyDocuments,
},
{
url: 'analytics-busy',
name: 'Busy Team',
members: [
{ name: 'Jonas Weber', role: TeamMemberRole.ADMIN },
{ name: 'Amara Okonkwo', role: TeamMemberRole.MANAGER },
{ name: 'Diego Alvarez', role: TeamMemberRole.MEMBER },
{ name: 'Hana Sato', role: TeamMemberRole.MEMBER },
{ name: 'Oliver Bennett', role: TeamMemberRole.MEMBER },
{ name: 'Chloe Dubois', role: TeamMemberRole.MEMBER },
{ name: 'Ravi Menon', role: TeamMemberRole.MEMBER },
],
templates: BUSY_TEMPLATES,
buildDocuments: buildBusyDocuments,
},
];
// ---------------------------------------------------------------------------
// Seeding helpers
// ---------------------------------------------------------------------------
const toEmailSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '.');
const getAdminUserAndOrganisation = async () => {
const admin = await prisma.user.findFirst({
where: {
email: ADMIN_EMAIL,
},
});
if (!admin) {
throw new Error(`User ${ADMIN_EMAIL} not found. Run the initial seed first (npm run prisma:seed).`);
}
const organisation = await prisma.organisation.findFirst({
where: {
ownerUserId: admin.id,
},
include: {
groups: true,
},
});
if (!organisation) {
throw new Error(`No organisation owned by ${ADMIN_EMAIL} was found.`);
}
return { admin, organisation };
};
/**
* Deletes an existing team with the given URL (and its documents) so the seed can
* recreate it from scratch. Mirrors the cleanup done by `deleteTeam`.
*/
const deleteExistingTeam = async (teamUrl: string, organisationId: string) => {
const existingTeam = await prisma.team.findUnique({
where: {
url: teamUrl,
},
include: {
teamGroups: {
select: {
organisationGroupId: true,
},
},
},
});
if (!existingTeam) {
return false;
}
if (existingTeam.organisationId !== organisationId) {
throw new Error(`Team "${teamUrl}" exists but belongs to a different organisation. Aborting.`);
}
// Captured before the delete cascades the team groups away, so only the groups
// that belonged to this team are considered for cleanup.
const organisationGroupIds = existingTeam.teamGroups.map((teamGroup) => teamGroup.organisationGroupId);
// Audit logs are only SetNull on envelope delete, so purge them explicitly.
await prisma.documentAuditLog.deleteMany({
where: {
envelope: {
teamId: existingTeam.id,
},
},
});
await prisma.$transaction(async (tx) => {
await tx.team.delete({
where: {
id: existingTeam.id,
},
});
await tx.organisationGroup.deleteMany({
where: {
id: {
in: organisationGroupIds,
},
type: OrganisationGroupType.INTERNAL_TEAM,
teamGroups: {
none: {},
},
},
});
});
return true;
};
/**
* Finds or creates a user and makes sure they are an organisation member.
* Returns the user and their organisation member id.
*/
const ensureOrganisationMember = async ({
name,
email,
organisationId,
}: {
name: string;
email: string;
organisationId: string;
}) => {
let user = await prisma.user.findFirst({
where: {
email,
},
});
if (!user) {
user = await prisma.user.create({
data: {
name,
email,
password: hashSync(ADMIN_PASSWORD),
emailVerified: new Date(),
},
});
}
let organisationMember = await prisma.organisationMember.findFirst({
where: {
userId: user.id,
organisationId,
},
});
if (!organisationMember) {
const organisationGroups = await prisma.organisationGroup.findMany({
where: {
organisationId,
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
});
await addUserToOrganisation({
userId: user.id,
organisationId,
organisationGroups,
organisationMemberRole: OrganisationMemberRole.MEMBER,
bypassEmail: true,
});
organisationMember = await prisma.organisationMember.findFirstOrThrow({
where: {
userId: user.id,
organisationId,
},
});
}
return { user, organisationMemberId: organisationMember.id };
};
const seedDocument = async ({
spec,
index,
teamId,
senders,
templateSecondaryIds,
}: {
spec: DocumentSpec;
index: number;
teamId: number;
senders: User[];
templateSecondaryIds: string[];
}) => {
const sender = senders[spec.senderIndex];
if (!sender) {
throw new Error(`Sender index ${spec.senderIndex} is out of range`);
}
const createdAt = atDaysAgo(spec.daysAgo);
const baseTitle = DOCUMENT_TITLES[index % DOCUMENT_TITLES.length];
const title =
index >= DOCUMENT_TITLES.length ? `${baseTitle} (${Math.floor(index / DOCUMENT_TITLES.length) + 1})` : baseTitle;
const isSent = spec.status !== DocumentStatus.DRAFT;
const isCompleted = spec.status === DocumentStatus.COMPLETED;
// Sent a few minutes to a couple of hours after creation.
const sentAt = notInFuture(new Date(createdAt.getTime() + randInt(5, 180) * MINUTE_MS));
// Completed 0.5-5 days after being sent.
const completedAt = notInFuture(new Date(sentAt.getTime() + randInt(12, 120) * HOUR_MS));
const templateSecondaryId = spec.templateIndex !== undefined ? templateSecondaryIds[spec.templateIndex] : undefined;
const envelope = await seedBlankDocument(sender, teamId, {
createDocumentOptions: {
title,
status: spec.status,
visibility: spec.visibility,
createdAt,
updatedAt: isCompleted ? completedAt : isSent ? sentAt : createdAt,
...(isCompleted ? { completedAt } : {}),
...(templateSecondaryId ? { templateId: mapSecondaryIdToTemplateId(templateSecondaryId) } : {}),
},
});
const recipientName = pick(RECIPIENT_NAMES);
await prisma.recipient.create({
data: {
envelopeId: envelope.id,
name: recipientName,
email: `${toEmailSlug(recipientName)}@example.com`,
token: nanoid(),
sendStatus: isSent ? SendStatus.SENT : SendStatus.NOT_SENT,
readStatus: isSent ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
signingStatus: isCompleted ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
signedAt: isCompleted ? completedAt : null,
},
});
if (isSent) {
await prisma.documentAuditLog.create({
data: {
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
createdAt: sentAt,
userId: sender.id,
email: sender.email,
name: sender.name,
data: {},
},
});
}
return envelope;
};
const seedAnalyticsTeam = async ({
spec,
admin,
organisationId,
}: {
spec: TeamSpec;
admin: User;
organisationId: string;
}): Promise<SeededTeamSummary> => {
console.log('');
console.log(`[SEEDING]: ${spec.name} (${spec.url})`);
const wasDeleted = await deleteExistingTeam(spec.url, organisationId);
if (wasDeleted) {
console.log(` Existing team "${spec.url}" deleted, recreating.`);
} else {
console.log(` No existing team "${spec.url}", creating.`);
}
// inheritMembers: false attaches only the org admin/manager groups, so the admin
// is a team ADMIN and every other member is attached explicitly below.
await createTeam({
userId: admin.id,
teamName: spec.name,
teamUrl: spec.url,
organisationId,
inheritMembers: false,
});
const team = await prisma.team.findUniqueOrThrow({
where: {
url: spec.url,
},
});
const members: User[] = [];
const membersToCreate: { organisationMemberId: string; teamRole: TeamMemberRole }[] = [];
for (const memberSpec of spec.members) {
const email = `${spec.url}-${toEmailSlug(memberSpec.name)}@${MEMBER_EMAIL_DOMAIN}`;
const { user, organisationMemberId } = await ensureOrganisationMember({
name: memberSpec.name,
email,
organisationId,
});
members.push(user);
membersToCreate.push({ organisationMemberId, teamRole: memberSpec.role });
}
await createTeamMembers({
userId: admin.id,
teamId: team.id,
membersToCreate,
});
console.log(` Members attached: ${members.length + 1} (incl. admin)`);
const templateSecondaryIds: string[] = [];
for (const templateSpec of spec.templates) {
const template = await seedBlankTemplate(admin, team.id, {
createTemplateOptions: {
title: templateSpec.title,
createdAt: atDaysAgo(BUSY_WINDOW_DAYS + 10),
},
});
templateSecondaryIds.push(template.secondaryId);
}
console.log(` Templates created: ${templateSecondaryIds.length}`);
const senders: User[] = [admin, ...members];
const documentSpecs = spec.buildDocuments();
let index = 0;
for (const documentSpec of documentSpecs) {
await seedDocument({ spec: documentSpec, index, teamId: team.id, senders, templateSecondaryIds });
index += 1;
}
console.log(` Documents created: ${documentSpecs.length}`);
const documentsByStatus = documentSpecs.reduce<Record<string, number>>((acc, documentSpec) => {
acc[documentSpec.status] = (acc[documentSpec.status] ?? 0) + 1;
return acc;
}, {});
const documentsByVisibility = documentSpecs.reduce<Record<string, number>>((acc, documentSpec) => {
acc[documentSpec.visibility] = (acc[documentSpec.visibility] ?? 0) + 1;
return acc;
}, {});
const documentsFromTemplates = documentSpecs.filter(
(documentSpec) => documentSpec.templateIndex !== undefined,
).length;
return {
url: team.url,
name: team.name,
memberCount: members.length + 1,
templateCount: templateSecondaryIds.length,
documentsByStatus,
documentsByVisibility,
documentsFromTemplates,
};
};
/**
* Re-queries the database so the printed summary reflects what was actually stored
* rather than what the specs intended.
*/
const verifyTeam = async (teamUrl: string) => {
const team = await prisma.team.findUniqueOrThrow({
where: {
url: teamUrl,
},
});
const memberCount = await prisma.organisationMember.count({
where: {
organisationGroupMembers: {
some: {
group: {
teamGroups: {
some: {
teamId: team.id,
},
},
},
},
},
},
});
const statusGroups = await prisma.envelope.groupBy({
by: ['status'],
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
},
_count: {
_all: true,
},
});
const templateCount = await prisma.envelope.count({
where: {
teamId: team.id,
type: EnvelopeType.TEMPLATE,
},
});
const sentLogCount = await prisma.documentAuditLog.count({
where: {
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
envelope: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
},
},
});
const fromTemplateCount = await prisma.envelope.count({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
templateId: {
not: null,
},
},
});
const last30Days = await prisma.envelope.count({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
createdAt: {
gte: new Date(NOW.getTime() - 30 * DAY_MS),
},
},
});
const last90Days = await prisma.envelope.count({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
createdAt: {
gte: new Date(NOW.getTime() - 90 * DAY_MS),
},
},
});
const byStatus = Object.fromEntries(statusGroups.map((group) => [group.status, group._count._all]));
return {
teamUrl,
memberCount,
templateCount,
byStatus,
totalDocuments: statusGroups.reduce((sum, group) => sum + group._count._all, 0),
sentLogCount,
fromTemplateCount,
last30Days,
last90Days,
};
};
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
const seedAnalytics = async () => {
if (process.env.NODE_ENV === 'production') {
throw new Error('The analytics seed deletes and recreates teams and must not run in production.');
}
const { admin, organisation } = await getAdminUserAndOrganisation();
console.log(`[SEEDING]: Using organisation "${organisation.name}" (${organisation.url}) owned by ${admin.email}`);
const summaries: SeededTeamSummary[] = [];
for (const [index, spec] of TEAM_SPECS.entries()) {
// Reset the generator per team so each team's shape is independent of the others.
resetRandom(20260922 + index * 1000);
summaries.push(await seedAnalyticsTeam({ spec, admin, organisationId: organisation.id }));
}
console.log('');
console.log('[SEEDING]: Verification (queried from database)');
for (const summary of summaries) {
const verified = await verifyTeam(summary.url);
console.log('');
console.log(` ${summary.name} - ${WEBAPP_URL}/t/${summary.url}/analytics`);
console.log(` Members: ${verified.memberCount}`);
console.log(` Templates: ${verified.templateCount}`);
console.log(` Documents: ${verified.totalDocuments} ${JSON.stringify(verified.byStatus)}`);
console.log(` Visibility: ${JSON.stringify(summary.documentsByVisibility)}`);
console.log(` DOCUMENT_SENT logs: ${verified.sentLogCount}`);
console.log(` From templates: ${verified.fromTemplateCount}`);
console.log(` Created last 30d: ${verified.last30Days}`);
console.log(` Created last 90d: ${verified.last90Days}`);
}
console.log('');
console.log('[SEEDING]: Done.');
console.log(` Admin email: ${ADMIN_EMAIL}`);
console.log(` Admin password: ${ADMIN_PASSWORD}`);
for (const summary of summaries) {
console.log(` ${WEBAPP_URL}/t/${summary.url}/analytics`);
}
};
const main = async () => {
try {
await seedAnalytics();
} catch (err) {
console.error('[SEEDING]: Failed to seed analytics teams.');
console.error(err);
process.exitCode = 1;
} finally {
await prisma.$disconnect();
}
};
if (require.main === module) {
void main();
}
+2 -1
View File
@@ -1,4 +1,5 @@
import {
NEXT_PRIVATE_SIGNING_REASON,
NEXT_PRIVATE_SIGNING_TRANSPORT,
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER,
NEXT_PUBLIC_SIGNING_CONTACT_INFO,
@@ -42,7 +43,7 @@ export const signPdf = async ({ pdf }: SignOptions) => {
const { bytes } = await pdf.sign({
signer,
reason: 'Signed by Documenso',
reason: NEXT_PRIVATE_SIGNING_REASON(),
location: NEXT_PUBLIC_WEBAPP_URL(),
contactInfo: NEXT_PUBLIC_SIGNING_CONTACT_INFO(),
subFilter: NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER() ? 'adbe.pkcs7.detached' : 'ETSI.CAdES.detached',
@@ -5,13 +5,13 @@ import type { Envelope, Prisma } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import { ZFindInboxRequestSchema, ZFindInboxResponseSchema } from './find-inbox.types';
import { type TInboxStatus, ZFindInboxRequestSchema, ZFindInboxResponseSchema } from './find-inbox.types';
export const findInboxRoute = authenticatedProcedure
.input(ZFindInboxRequestSchema)
.output(ZFindInboxResponseSchema)
.query(async ({ input, ctx }) => {
const { page, perPage } = input;
const { page, perPage, query, status } = input;
const userId = ctx.user.id;
@@ -19,6 +19,8 @@ export const findInboxRoute = authenticatedProcedure
userId,
page,
perPage,
query,
status,
});
return {
@@ -31,13 +33,22 @@ export type FindInboxOptions = {
userId: number;
page?: number;
perPage?: number;
/**
* Case insensitive search against the document title.
*/
query?: string;
/**
* Restrict results to a single status. When omitted, every non-draft status is returned.
*/
status?: TInboxStatus;
orderBy?: {
column: keyof Omit<Envelope, 'envelope'>;
direction: 'asc' | 'desc';
};
};
export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: FindInboxOptions) => {
export const findInbox = async ({ userId, page = 1, perPage = 10, query = '', status, orderBy }: FindInboxOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@@ -50,10 +61,11 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: Fin
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
const searchQuery = query.trim();
const whereClause: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT,
status: {
status: status ?? {
not: DocumentStatus.DRAFT,
},
deletedAt: null,
@@ -67,6 +79,13 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: Fin
},
};
if (searchQuery.length > 0) {
whereClause.title = {
contains: searchQuery,
mode: 'insensitive',
};
}
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: whereClause,
@@ -2,12 +2,33 @@
import { ZDocumentManySchema } from '@documenso/lib/types/document';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import type { z } from 'zod';
import { DocumentStatus } from '@prisma/client';
import { z } from 'zod';
export const ZFindInboxRequestSchema = ZFindSearchParamsSchema;
/**
* The statuses that can be filtered by in the inbox.
*
* Every document status except DRAFT, since drafts have not been sent to
* recipients yet and must never be visible in the inbox.
*/
export const INBOX_STATUSES = [
DocumentStatus.PENDING,
DocumentStatus.COMPLETED,
DocumentStatus.REJECTED,
DocumentStatus.CANCELLED,
] as const;
export const ZInboxStatusSchema = z.enum(INBOX_STATUSES);
export type TInboxStatus = z.infer<typeof ZInboxStatusSchema>;
export const ZFindInboxRequestSchema = ZFindSearchParamsSchema.extend({
status: ZInboxStatusSchema.describe('Filter the inbox by document status.').optional(),
});
export const ZFindInboxResponseSchema = ZFindResultResponse.extend({
data: ZDocumentManySchema.array(),
});
export type TFindInboxRequest = z.infer<typeof ZFindInboxRequestSchema>;
export type TFindInboxResponse = z.infer<typeof ZFindInboxResponseSchema>;
@@ -18,6 +18,5 @@ export const embeddingPresignRouter = router({
updateEmbeddingEnvelope: updateEmbeddingEnvelopeRoute,
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
// applyMultiSignSignature: applyMultiSignSignatureRoute,
getMultiSignDocument: getMultiSignDocumentRoute,
});
@@ -1,98 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { FieldType, ReadStatus, SigningStatus } from '@prisma/client';
import { procedure } from '../trpc';
import {
ZApplyMultiSignSignatureRequestSchema,
ZApplyMultiSignSignatureResponseSchema,
} from './apply-multi-sign-signature.types';
export const applyMultiSignSignatureRoute = procedure
.input(ZApplyMultiSignSignatureRequestSchema)
.output(ZApplyMultiSignSignatureResponseSchema)
.mutation(async ({ input, ctx: { metadata } }) => {
try {
const { tokens, signature, isBase64 } = input;
// Get all documents and recipients for the tokens
const envelopes = await Promise.all(
tokens.map(async (token) => {
const document = await getDocumentByToken({ token });
const recipient = await getRecipientByToken({ token });
return { document, recipient };
}),
);
// Check if all documents have been viewed
const hasUnviewedDocuments = envelopes.some((envelope) => envelope.recipient.readStatus !== ReadStatus.OPENED);
if (hasUnviewedDocuments) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'All documents must be viewed before signing',
});
}
// If we require action auth we should abort here for now
for (const envelope of envelopes) {
const derivedRecipientActionAuth = extractDocumentAuthMethods({
documentAuth: envelope.document.authOptions,
recipientAuth: envelope.recipient.authOptions,
});
if (
derivedRecipientActionAuth.recipientAccessAuthRequired ||
derivedRecipientActionAuth.recipientActionAuthRequired
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Documents that require additional authentication cannot be multi signed at the moment',
});
}
}
// Sign all signature fields for each document
await Promise.all(
envelopes.map(async (envelope) => {
if (envelope.recipient.signingStatus === SigningStatus.REJECTED) {
return;
}
const signatureFields = await prisma.field.findMany({
where: {
envelopeId: envelope.document.id,
recipientId: envelope.recipient.id,
type: FieldType.SIGNATURE,
inserted: false,
},
});
await Promise.all(
signatureFields.map(async (field) =>
signFieldWithToken({
token: envelope.recipient.token,
fieldId: field.id,
value: signature,
isBase64,
requestMetadata: metadata.requestMetadata,
}),
),
);
}),
);
return { success: true };
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to apply multi-sign signature',
});
}
});
@@ -1,14 +0,0 @@
import { z } from 'zod';
export const ZApplyMultiSignSignatureRequestSchema = z.object({
tokens: z.array(z.string()).min(1, { message: 'At least one token is required' }),
signature: z.string().min(1, { message: 'Signature is required' }),
isBase64: z.boolean().optional().default(false),
});
export const ZApplyMultiSignSignatureResponseSchema = z.object({
success: z.boolean(),
});
export type TApplyMultiSignSignatureRequestSchema = z.infer<typeof ZApplyMultiSignSignatureRequestSchema>;
export type TApplyMultiSignSignatureResponseSchema = z.infer<typeof ZApplyMultiSignSignatureResponseSchema>;

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