Compare commits

..

1 Commits

Author SHA1 Message Date
a66a56042c feat: add envelopes api 2025-10-28 12:13:26 +11:00
90 changed files with 1191 additions and 3425 deletions

View File

@ -1,692 +0,0 @@
# Documenso Code Style Guide
This document captures the code style, patterns, and conventions used in the Documenso codebase. It covers both enforceable rules and subjective "taste" elements that make our code consistent and maintainable.
## Table of Contents
1. [General Principles](#general-principles)
2. [TypeScript Conventions](#typescript-conventions)
3. [Imports & Dependencies](#imports--dependencies)
4. [Functions & Methods](#functions--methods)
5. [React & Components](#react--components)
6. [Error Handling](#error-handling)
7. [Async/Await Patterns](#asyncawait-patterns)
8. [Whitespace & Formatting](#whitespace--formatting)
9. [Naming Conventions](#naming-conventions)
10. [Pattern Matching](#pattern-matching)
11. [Database & Prisma](#database--prisma)
12. [TRPC Patterns](#trpc-patterns)
---
## General Principles
- **Functional over Object-Oriented**: Prefer functional programming patterns over classes
- **Explicit over Implicit**: Be explicit about types, return values, and error cases
- **Early Returns**: Use guard clauses and early returns to reduce nesting
- **Immutability**: Favor `const` over `let`; avoid mutation where possible
---
## TypeScript Conventions
### Type Definitions
```typescript
// ✅ Prefer `type` over `interface`
type CreateDocumentOptions = {
templateId: number;
userId: number;
recipients: Recipient[];
};
// ❌ Avoid interfaces unless absolutely necessary
interface CreateDocumentOptions {
templateId: number;
}
```
### Type Imports
```typescript
// ✅ Use `type` keyword for type-only imports
import type { Document, Recipient } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
// Types in function signatures
export const findDocuments = async ({ userId, teamId }: FindDocumentsOptions) => {
// ...
};
```
### Inline Types for Function Parameters
```typescript
// ✅ Extract inline types to named types
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
templateRecipientId: number;
fields: Field[];
};
const finalRecipients: FinalRecipient[] = [];
```
---
## Imports & Dependencies
### Import Organization
Imports should be organized in the following order with blank lines between groups:
```typescript
// 1. React imports
import { useCallback, useEffect, useMemo } from 'react';
// 2. Third-party library imports (alphabetically)
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { Document, Recipient } from '@prisma/client';
import { DocumentStatus, RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';
// 3. Internal package imports (from @documenso/*)
import { AppError } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
// 4. Relative imports
import { getTeamById } from '../team/get-team';
import type { FindResultResponse } from './types';
```
### Destructuring Imports
```typescript
// ✅ Destructure specific exports
// ✅ Use type imports for types
import type { Document } from '@prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
```
---
## Functions & Methods
### Arrow Functions
```typescript
// ✅ Always use arrow functions for functions
export const createDocument = async ({
userId,
title,
}: CreateDocumentOptions) => {
// ...
};
// ✅ Callbacks and handlers
const onSubmit = useCallback(async () => {
// ...
}, [dependencies]);
// ❌ Avoid regular function declarations
function createDocument() {
// ...
}
```
### Function Parameters
```typescript
// ✅ Use destructured object parameters for multiple params
export const findDocuments = async ({
userId,
teamId,
status = ExtendedDocumentStatus.ALL,
page = 1,
perPage = 10,
}: FindDocumentsOptions) => {
// ...
};
// ✅ Destructure on separate line when needed
const onFormSubmit = form.handleSubmit(onSubmit);
// ✅ Deconstruct nested properties explicitly
const { user } = ctx;
const { templateId } = input;
```
---
## React & Components
### Component Definition
```typescript
// ✅ Use const with arrow function
export const AddSignersFormPartial = ({
documentFlow,
recipients,
fields,
onSubmit,
}: AddSignersFormProps) => {
// ...
};
// ❌ Never use classes
class MyComponent extends React.Component {
// ...
}
```
### Hooks
```typescript
// ✅ Group related hooks together with blank line separation
const { _ } = useLingui();
const { toast } = useToast();
const { currentStep, totalSteps, previousStep } = useStep();
const form = useForm<TFormSchema>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
// ...
},
});
```
### Event Handlers
```typescript
// ✅ Use arrow functions with descriptive names
const onFormSubmit = async () => {
await form.trigger();
// ...
};
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null) => {
event?.preventDefault();
// ...
},
[dependencies],
);
// ✅ Inline handlers for simple operations
<Button onClick={() => setOpen(false)}>Close</Button>
```
### State Management
```typescript
// ✅ Descriptive state names with auxiliary verbs
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
// ✅ Complex state in single useState when related
const [coords, setCoords] = useState({
x: 0,
y: 0,
});
```
---
## Error Handling
### Try-Catch Blocks
```typescript
// ✅ Use try-catch for operations that might fail
try {
const document = await getDocumentById({
documentId: Number(documentId),
userId: user.id,
});
return {
status: 200,
body: document,
};
} catch (err) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
```
### Throwing Errors
```typescript
// ✅ Use AppError for application errors
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
// ✅ Use descriptive error messages
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Template with ID ${templateId} not found`,
});
}
```
### Error Parsing on Frontend
```typescript
// ✅ Parse errors on the frontend
try {
await updateOrganisation({ organisationId, data });
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An error occurred`,
description: error.message,
variant: 'destructive',
});
}
```
---
## Async/Await Patterns
### Async Function Definitions
```typescript
// ✅ Mark async functions clearly
export const createDocument = async ({
userId,
title,
}: Options): Promise<Document> => {
// ...
};
// ✅ Use await for promises
const document = await prisma.document.create({ data });
// ✅ Use Promise.all for parallel operations
const [document, recipients] = await Promise.all([
getDocumentById({ documentId }),
getRecipientsForDocument({ documentId }),
]);
```
### Void for Fire-and-Forget
```typescript
// ✅ Use void for intentionally unwaited promises
void handleAutoSave();
// ✅ Or in event handlers
onClick={() => void onFormSubmit()}
```
---
## Whitespace & Formatting
### Blank Lines Between Concepts
```typescript
// ✅ Blank line after imports
import { prisma } from '@documenso/prisma';
export const findDocuments = async () => {
// ...
};
// ✅ Blank line between logical sections
const user = await prisma.user.findFirst({ where: { id: userId } });
let team = null;
if (teamId !== undefined) {
team = await getTeamById({ userId, teamId });
}
// ✅ Blank line before return statements
const result = await someOperation();
return result;
```
### Function/Method Spacing
```typescript
// ✅ No blank lines between chained methods in same operation
const documents = await prisma.document
.findMany({ where: { userId } })
.then((docs) => docs.map(maskTokens));
// ✅ Blank line between different operations
const document = await createDocument({ userId });
await sendDocument({ documentId: document.id });
return document;
```
### Object and Array Formatting
```typescript
// ✅ Multi-line when complex
const options = {
userId,
teamId,
status: ExtendedDocumentStatus.ALL,
page: 1,
};
// ✅ Single line when simple
const coords = { x: 0, y: 0 };
// ✅ Array items on separate lines when objects
const recipients = [
{
name: 'John',
email: 'john@example.com',
},
{
name: 'Jane',
email: 'jane@example.com',
},
];
```
---
## Naming Conventions
### Variables
```typescript
// ✅ camelCase for variables and functions
const documentId = 123;
const onSubmit = () => {};
// ✅ Descriptive names with auxiliary verbs for booleans
const isLoading = false;
const hasError = false;
const canEdit = true;
const shouldRender = true;
// ✅ Prefix with $ for DOM elements
const $page = document.querySelector('.page');
const $inputRef = useRef<HTMLInputElement>(null);
```
### Types and Schemas
```typescript
// ✅ PascalCase for types
type CreateDocumentOptions = {
userId: number;
};
// ✅ Prefix Zod schemas with Z
const ZCreateDocumentSchema = z.object({
title: z.string(),
});
// ✅ Prefix type from Zod schema with T
type TCreateDocumentSchema = z.infer<typeof ZCreateDocumentSchema>;
```
### Constants
```typescript
// ✅ UPPER_SNAKE_CASE for true constants
const DEFAULT_DOCUMENT_DATE_FORMAT = 'dd/MM/yyyy';
const MAX_FILE_SIZE = 1024 * 1024 * 5;
// ✅ camelCase for const variables that aren't "constants"
const userId = await getUserId();
```
### Functions
```typescript
// ✅ Verb-based names for functions
const createDocument = async () => {};
const findDocuments = async () => {};
const updateDocument = async () => {};
const deleteDocument = async () => {};
// ✅ On prefix for event handlers
const onSubmit = () => {};
const onClick = () => {};
const onFieldCopy = () => {}; // 'on' is also acceptable
```
### Clarity Over Brevity
```typescript
// ✅ Prefer descriptive names over abbreviations
const superLongMethodThatIsCorrect = () => {};
const recipientAuthenticationOptions = {};
const documentMetadata = {};
// ❌ Avoid abbreviations that sacrifice clarity
const supLongMethThatIsCorrect = () => {};
const recipAuthOpts = {};
const docMeta = {};
// ✅ Common abbreviations that are widely understood are acceptable
const userId = 123;
const htmlElement = document.querySelector('div');
const apiResponse = await fetch('/api');
```
---
## Pattern Matching
### Using ts-pattern
```typescript
import { match } from 'ts-pattern';
// ✅ Use match for complex conditionals
const result = match(status)
.with(ExtendedDocumentStatus.DRAFT, () => ({
status: 'draft',
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
status: 'pending',
}))
.with(ExtendedDocumentStatus.COMPLETED, () => ({
status: 'completed',
}))
.exhaustive();
// ✅ Use .otherwise() for default case when not exhaustive
const value = match(type)
.with('text', () => 'Text field')
.with('number', () => 'Number field')
.otherwise(() => 'Unknown field');
```
---
## Database & Prisma
### Query Structure
```typescript
// ✅ Destructure commonly used fields
const { id, email, name } = user;
// ✅ Use select to limit returned fields
const user = await prisma.user.findFirst({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
},
});
// ✅ Use include for relations
const document = await prisma.document.findFirst({
where: { id: documentId },
include: {
recipients: true,
fields: true,
},
});
```
### Transactions
```typescript
// ✅ Use transactions for related operations
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({ data });
await tx.field.createMany({ data: fieldsData });
await tx.documentAuditLog.create({ data: auditData });
return document;
});
```
### Where Clauses
```typescript
// ✅ Build complex where clauses separately
const whereClause: Prisma.DocumentWhereInput = {
AND: [
{ userId: user.id },
{ deletedAt: null },
{ status: { in: [DocumentStatus.DRAFT, DocumentStatus.PENDING] } },
],
};
const documents = await prisma.document.findMany({
where: whereClause,
});
```
---
## TRPC Patterns
### Router Structure
```typescript
// ✅ Destructure context and input at start
.query(async ({ input, ctx }) => {
const { teamId } = ctx;
const { templateId } = input;
ctx.logger.info({
input: { templateId },
});
return await getTemplateById({
id: templateId,
userId: ctx.user.id,
teamId,
});
});
```
### Request/Response Schemas
```typescript
// ✅ Name schemas clearly
const ZCreateDocumentRequestSchema = z.object({
title: z.string(),
recipients: z.array(ZRecipientSchema),
});
const ZCreateDocumentResponseSchema = z.object({
documentId: z.number(),
status: z.string(),
});
```
### Error Handling in TRPC
```typescript
// ✅ Catch and transform errors appropriately
try {
const result = await createDocument({ userId, data });
return result;
} catch (err) {
return AppError.toRestAPIError(err);
}
// ✅ Or throw AppError directly
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
```
---
## Additional Patterns
### Optional Chaining
```typescript
// ✅ Use optional chaining for potentially undefined values
const email = user?.email;
const recipientToken = recipient?.token ?? '';
// ✅ Use nullish coalescing for defaults
const pageSize = perPage ?? 10;
const status = documentStatus ?? DocumentStatus.DRAFT;
```
### Array Operations
```typescript
// ✅ Use functional array methods
const activeRecipients = recipients.filter((r) => r.signingStatus === 'SIGNED');
const recipientEmails = recipients.map((r) => r.email);
const hasSignedRecipients = recipients.some((r) => r.signingStatus === 'SIGNED');
// ✅ Use find instead of filter + [0]
const recipient = recipients.find((r) => r.id === recipientId);
```
### Conditional Rendering
```typescript
// ✅ Use && for conditional rendering
{isLoading && <Loader />}
// ✅ Use ternary for either/or
{isLoading ? <Loader /> : <Content />}
// ✅ Extract complex conditions to variables
const shouldShowAdvanced = isAdmin && hasPermission && !isDisabled;
{shouldShowAdvanced && <AdvancedSettings />}
```
---
## When in Doubt
- **Consistency**: Follow the patterns you see in similar files
- **Readability**: Favor code that's easy to read over clever one-liners
- **Explicitness**: Be explicit rather than implicit
- **Whitespace**: Use blank lines to separate logical sections
- **Early Returns**: Use guard clauses to reduce nesting
- **Functional**: Prefer functional patterns over imperative ones

View File

@ -22,15 +22,6 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
- `document.completed`
- `document.rejected`
- `document.cancelled`
- `document.viewed`
- `document.recipient.completed`
- `document.downloaded`
- `document.reminder.sent`
- `template.created`
- `template.updated`
- `template.deleted`
- `template.used`
- `recipient.authentication.failed`
## Create a webhook subscription
@ -47,7 +38,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo
To create a new webhook subscription, you need to provide the following information:
- Enter the webhook URL that will receive the event payload.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`, `document.viewed`, `document.recipient.completed`, `document.downloaded`, `document.reminder.sent`, `template.created`, `template.updated`, `template.deleted`, `template.used`, `recipient.authentication.failed`.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp)
@ -628,591 +619,6 @@ Example payload for the `document.rejected` event:
}
```
Example payload for the `document.viewed` event:
```json
{
"event": "DOCUMENT_VIEWED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "John Doe",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:50:26.174Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.recipient.completed` event:
```json
{
"event": "DOCUMENT_RECIPIENT_COMPLETED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:51:10.055Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 50,
"documentId": 10,
"templateId": null,
"email": "signer1@documenso.com",
"name": "Signer 1",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": "2024-04-22T11:51:10.055Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
},
{
"id": 51,
"documentId": 10,
"templateId": null,
"email": "signer2@documenso.com",
"name": "Signer 2",
"token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 2,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:51:10.577Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.downloaded` event:
```json
{
"event": "DOCUMENT_DOWNLOADED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "COMPLETED",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:52:05.708Z",
"completedAt": "2024-04-22T11:52:05.707Z",
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 51,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expired": null,
"signedAt": "2024-04-22T11:52:05.688Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:53:18.577Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.reminder.sent` event:
```json
{
"event": "DOCUMENT_REMINDER_SENT",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T12:00:00.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.created` event:
```json
{
"event": "TEMPLATE_CREATED",
"payload": {
"id": 5,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract.pdf",
"status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:44:43.341Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_456",
"subject": "Employment Contract",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 25,
"documentId": null,
"templateId": 5,
"email": "employee@company.com",
"name": "Employee",
"token": "TemplateToken123",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T11:44:44.779Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.updated` event:
```json
{
"event": "TEMPLATE_UPDATED",
"payload": {
"id": 5,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract_v2.pdf",
"status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T12:30:00.000Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_456",
"subject": "Employment Contract - Updated",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 25,
"documentId": null,
"templateId": 5,
"email": "employee@company.com",
"name": "Employee",
"token": "TemplateToken123",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T12:30:01.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.deleted` event:
```json
{
"event": "TEMPLATE_DELETED",
"payload": {
"id": 5,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract.pdf",
"status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:44:43.341Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_456",
"subject": "Employment Contract",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 25,
"documentId": null,
"templateId": 5,
"email": "employee@company.com",
"name": "Employee",
"token": "TemplateToken123",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T13:00:00.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.used` event:
```json
{
"event": "TEMPLATE_USED",
"payload": {
"id": 15,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract.pdf",
"status": "DRAFT",
"documentDataId": "new_doc_data_123",
"createdAt": "2024-04-22T14:00:00.000Z",
"updatedAt": "2024-04-22T14:00:00.000Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_789",
"subject": "Employment Contract",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 60,
"documentId": 15,
"templateId": 5,
"email": "newemployee@company.com",
"name": "New Employee",
"token": "DocToken456",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T14:00:01.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `recipient.authentication.failed` event:
```json
{
"event": "RECIPIENT_AUTHENTICATION_FAILED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": {
"accessAuth": "TWO_FACTOR_AUTH",
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:49:00.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
## Webhook Events Testing
You can trigger test webhook events to test the webhook functionality. To trigger a test webhook, navigate to the [Webhooks page](/developers/webhooks) and click on the "Test Webhook" button.

View File

@ -1,218 +0,0 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminOrganisationMemberUpdateDialogProps = {
trigger?: React.ReactNode;
organisationId: string;
organisationMember: TGetAdminOrganisationResponse['members'][number];
isOwner: boolean;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateOrganisationMemberFormSchema = z.object({
role: z.enum(['OWNER', 'ADMIN', 'MANAGER', 'MEMBER']),
});
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
export const AdminOrganisationMemberUpdateDialog = ({
trigger,
organisationId,
organisationMember,
isOwner,
...props
}: AdminOrganisationMemberUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
// Determine the current role value for the form
const currentRoleValue = isOwner
? 'OWNER'
: getHighestOrganisationRoleInGroup(
organisationMember.organisationGroupMembers.map((ogm) => ogm.group),
);
const organisationMemberName = organisationMember.user.name ?? organisationMember.user.email;
const form = useForm<ZUpdateOrganisationMemberSchema>({
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
defaultValues: {
role: currentRoleValue,
},
});
const { mutateAsync: updateOrganisationMemberRole } =
trpc.admin.organisationMember.updateRole.useMutation();
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
try {
await updateOrganisationMemberRole({
organisationId,
userId: organisationMember.userId,
role,
});
const roleLabel = match(role)
.with('OWNER', () => t`Owner`)
.with(OrganisationMemberRole.ADMIN, () => t`Admin`)
.with(OrganisationMemberRole.MANAGER, () => t`Manager`)
.with(OrganisationMemberRole.MEMBER, () => t`Member`)
.exhaustive();
toast({
title: t`Success`,
description:
role === 'OWNER'
? t`Ownership transferred to ${organisationMemberName}.`
: t`Updated ${organisationMemberName} to ${roleLabel}.`,
duration: 5000,
});
setOpen(false);
// Refresh the page to show updated data
await navigate(0);
} catch (err) {
console.error(err);
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to update this organisation member. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
return;
}
form.reset({
role: currentRoleValue,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentRoleValue, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Update role</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Update organisation member</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are currently updating{' '}
<span className="font-bold">{organisationMemberName}.</span>
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel required>
<Trans>Role</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="w-full" position="popper">
<SelectItem value="OWNER">
<Trans>Owner</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.ADMIN}>
<Trans>Admin</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.MANAGER}>
<Trans>Manager</Trans>
</SelectItem>
<SelectItem value={OrganisationMemberRole.MEMBER}>
<Trans>Member</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -89,10 +89,7 @@ export const DirectTemplatePageView = ({
setStep('sign');
};
const onSignDirectTemplateSubmit = async (
fields: DirectTemplateLocalField[],
nextSigner?: { name: string; email: string },
) => {
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
try {
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
@ -101,7 +98,6 @@ export const DirectTemplatePageView = ({
}
const { token } = await createDocumentFromDirectTemplate({
nextSigner,
directTemplateToken,
directTemplateExternalId,
directRecipientName: fullName,

View File

@ -55,13 +55,10 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s
export type DirectTemplateSigningFormProps = {
flowStep: DocumentFlowStep;
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'>;
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
directRecipientFields: Field[];
template: Omit<TTemplate, 'user'>;
onSubmit: (
_data: DirectTemplateLocalField[],
_nextSigner?: { name: string; email: string },
) => Promise<void>;
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
};
export type DirectTemplateLocalField = Field & {
@ -152,7 +149,7 @@ export const DirectTemplateSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation);
};
const handleSubmit = async (nextSigner?: { name: string; email: string }) => {
const handleSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
@ -164,7 +161,7 @@ export const DirectTemplateSigningForm = ({
setIsSubmitting(true);
try {
await onSubmit(localFields, nextSigner);
await onSubmit(localFields);
} catch {
setIsSubmitting(false);
}
@ -221,30 +218,6 @@ export const DirectTemplateSigningForm = ({
setLocalFields(updatedFields);
}, []);
const nextRecipient = useMemo(() => {
if (
!template.templateMeta?.signingOrder ||
template.templateMeta.signingOrder !== 'SEQUENTIAL' ||
!template.templateMeta.allowDictateNextSigner
) {
return undefined;
}
const sortedRecipients = template.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
return (
<DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
@ -444,15 +417,11 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)}
onSignatureComplete={async () => handleSubmit()}
documentTitle={template.title}
fields={localFields}
fieldsValidated={fieldsValidated}
recipient={directRecipient}
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
/>
</div>
</DocumentFlowFormContainerFooter>

View File

@ -9,7 +9,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentSigningAuthPageViewProps = {
email?: string;
email: string;
emailHasAccount?: boolean;
};
@ -22,18 +22,12 @@ export const DocumentSigningAuthPageView = ({
const [isSigningOut, setIsSigningOut] = useState(false);
const handleChangeAccount = async (email?: string) => {
const handleChangeAccount = async (email: string) => {
try {
setIsSigningOut(true);
let redirectPath = '/signin';
if (email) {
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
}
await authClient.signOut({
redirectPath,
redirectPath: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`,
});
} catch {
toast({
@ -55,13 +49,9 @@ export const DocumentSigningAuthPageView = ({
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{email ? (
<Trans>
You need to be logged in as <strong>{email}</strong> to view this page.
</Trans>
) : (
<Trans>You need to be logged in to view this page.</Trans>
)}
<Trans>
You need to be logged in as <strong>{email}</strong> to view this page.
</Trans>
</p>
<Button

View File

@ -24,10 +24,7 @@ type PasskeyData = {
isError: boolean;
};
type SigningAuthRecipient = Pick<
Recipient,
'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'
>;
type SigningAuthRecipient = Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>;
export type DocumentSigningAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;

View File

@ -304,6 +304,7 @@ export const DocumentSigningCompleteDialog = ({
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && (
<div className="mb-4 flex flex-col gap-4">
{/* Todo: Envelopes - Should we say "The next recipient to sign this document will be"? */}
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}

View File

@ -285,6 +285,8 @@ export const EnvelopeSigningProvider = ({
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
console.log('insertField', fieldId, fieldValue);
// Set the field locally for direct templates.
if (isDirectTemplate) {
handleDirectTemplateFieldInsertion(fieldId, fieldValue);

View File

@ -127,7 +127,6 @@ export const EnvelopeSignerCompleteDialog = () => {
isBase64,
};
}),
nextSigner,
});
const redirectUrl = envelope.documentMeta.redirectUrl;

View File

@ -6,7 +6,6 @@ import { FolderIcon, HomeIcon } from 'lucide-react';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_ENVELOPES_ENABLED } from '@documenso/lib/constants/app';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
@ -99,7 +98,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
</div>
<div className="flex gap-4 sm:flex-row sm:justify-end">
{(IS_ENVELOPES_ENABLED || organisation.organisationClaim.flags.allowEnvelopes) && (
{organisation.organisationClaim.flags.allowEnvelopes && (
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
)}

View File

@ -116,7 +116,7 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && !hideHeader && <AppBanner banner={banner} />}
{banner && <AppBanner banner={banner} />}
{!hideHeader && <Header />}

View File

@ -34,7 +34,6 @@ import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
@ -72,6 +71,23 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
},
});
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
trpc.admin.organisationMember.promoteToOwner.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`Member promoted to owner successfully`,
});
},
onError: () => {
toast({
title: t`Error`,
description: t`We couldn't promote the member to owner. Please try again.`,
variant: 'destructive',
});
},
});
const teamsColumns = useMemo(() => {
return [
{
@ -104,24 +120,23 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
},
{
header: t`Actions`,
cell: ({ row }) => {
const isOwner = row.original.userId === organisation?.ownerUserId;
return (
<div className="flex justify-end space-x-2">
<AdminOrganisationMemberUpdateDialog
trigger={
<Button variant="outline">
<Trans>Update role</Trans>
</Button>
}
organisationId={organisationId}
organisationMember={row.original}
isOwner={isOwner}
/>
</div>
);
},
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button
variant="outline"
disabled={row.original.userId === organisation?.ownerUserId}
loading={isPromotingToOwner}
onClick={async () =>
promoteToOwner({
organisationId,
userId: row.original.userId,
})
}
>
<Trans>Promote to owner</Trans>
</Button>
</div>
),
},
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
}, [organisation]);

View File

@ -8,6 +8,7 @@ import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/env
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
@ -97,12 +98,15 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
envelopeForSigning,
} as const;
})
.catch((e) => {
.catch(async (e) => {
const error = AppError.parseError(e);
if (error.code === AppErrorCode.UNAUTHORIZED) {
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
return {
isDocumentAccessValid: false,
...requiredAccessData,
} as const;
}
@ -222,21 +226,20 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
const user = sessionData?.user;
if (!data.isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
return (
<DocumentSigningAuthPageView
email={data.recipientEmail}
emailHasAccount={!!data.recipientHasAccount}
/>
);
}
const { envelope, recipient } = data.envelopeForSigning;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
});
const isEmailForced = derivedRecipientAccessAuth.includes(DocumentAccessAuth.ACCOUNT);
return (
<EnvelopeSigningProvider
envelopeData={data.envelopeForSigning}
email={isEmailForced ? user?.email || '' : ''} // Doing this allows us to let users change the email if they want to for non-auth templates.
email={''} // Doing this allows us to let users change the email if they want to.
fullName={user?.name}
signature={user?.signature}
>

View File

@ -20,12 +20,12 @@ import {
getEnvelopeWhereInput,
} from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field';
import { updateDocumentFields } from '@documenso/lib/server-only/field/update-document-fields';
import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { deleteDocumentRecipient } from '@documenso/lib/server-only/recipient/delete-document-recipient';
import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/delete-envelope-recipient';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients';
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
@ -1285,7 +1285,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const updatedRecipient = await updateDocumentRecipients({
const updatedRecipient = await updateEnvelopeRecipients({
userId: user.id,
teamId: team.id,
id: {
@ -1336,7 +1336,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
},
});
const deletedRecipient = await deleteDocumentRecipient({
const deletedRecipient = await deleteEnvelopeRecipient({
userId: user.id,
teamId: team.id,
recipientId: Number(recipientId),
@ -1634,10 +1634,13 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const { fields } = await updateDocumentFields({
const { fields } = await updateEnvelopeFields({
userId: user.id,
teamId: team.id,
documentId: legacyDocumentId,
id: {
type: 'documentId',
id: legacyDocumentId,
},
fields: [
{
id: Number(fieldId),

View File

@ -68,29 +68,15 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
// Test promoting a MEMBER to owner
const memberRow = page.getByRole('row', { name: memberUser.email });
// Find and click the "Update role" button for the member
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
});
await expect(updateRoleButton).toBeVisible();
await expect(updateRoleButton).not.toBeDisabled();
// Find and click the "Promote to owner" button for the member
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton).toBeVisible();
await expect(promoteButton).not.toBeDisabled();
await updateRoleButton.click();
await promoteButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Verify success toast appears
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
// Reload the page to see the changes
await page.reload();
@ -103,18 +89,12 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify that the Update role button exists for the new owner and shows Owner as current role
const newOwnerUpdateButton = newOwnerRow.getByRole('button', {
name: 'Update role',
});
await expect(newOwnerUpdateButton).toBeVisible();
// Verify that the promote button is now disabled for the new owner
const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' });
await expect(newOwnerPromoteButton).toBeDisabled();
// Verify clicking it shows the dialog with Owner already selected
await newOwnerUpdateButton.click();
await expect(page.getByRole('dialog')).toBeVisible();
// Close the dialog without making changes
await page.getByRole('button', { name: 'Cancel' }).click();
// Test that we can't promote the current owner (button should be disabled)
await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
});
test('[ADMIN]: promote manager to owner', async ({ page }) => {
@ -150,26 +130,10 @@ test('[ADMIN]: promote manager to owner', async ({ page }) => {
// Promote the manager to owner
const managerRow = page.getByRole('row', { name: managerUser.email });
const updateRoleButton = managerRow.getByRole('button', {
name: 'Update role',
});
const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
// Reload and verify the change
await page.reload();
@ -209,27 +173,14 @@ test('[ADMIN]: promote admin member to owner', async ({ page }) => {
// Promote the admin member to owner
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
const updateRoleButton = adminMemberRow.getByRole('button', {
name: 'Update role',
const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload and verify the change
await page.reload();
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
@ -298,25 +249,11 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Promote member to owner
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload page to see updated state
await page.reload();
@ -325,11 +262,9 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
memberRow = page.getByRole('row', { name: memberUser.email });
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
// Verify the Update role button exists and shows Owner as current role
const newOwnerUpdateButton = memberRow.getByRole('button', {
name: 'Update role',
});
await expect(newOwnerUpdateButton).toBeVisible();
// Verify the promote button is now disabled for the new owner
const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await expect(newOwnerPromoteButton).toBeDisabled();
// Sign in as the newly promoted user to verify they have owner permissions
await apiSignin({
@ -401,56 +336,28 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
// First promotion: Member 1 becomes owner
let member1Row = page.getByRole('row', { name: member1User.email });
let updateRoleButton1 = member1Row.getByRole('button', {
name: 'Update role',
let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await promoteButton1.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
await updateRoleButton1.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await page.reload();
// Verify Member 1 is now owner
// Verify Member 1 is now owner and button is disabled
member1Row = page.getByRole('row', { name: member1User.email });
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
updateRoleButton1 = member1Row.getByRole('button', { name: 'Update role' });
await expect(updateRoleButton1).toBeVisible();
promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton1).toBeDisabled();
// Second promotion: Member 2 becomes the new owner
const member2Row = page.getByRole('row', { name: member2User.email });
const updateRoleButton2 = member2Row.getByRole('button', {
name: 'Update role',
const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton2).not.toBeDisabled();
await promoteButton2.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
await expect(updateRoleButton2).toBeVisible();
await updateRoleButton2.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await page.reload();
@ -458,11 +365,9 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify Member 1's Update role button is still visible
const newUpdateButton1 = member1Row.getByRole('button', {
name: 'Update role',
});
await expect(newUpdateButton1).toBeVisible();
// Verify Member 1's promote button is now enabled again
const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await expect(newPromoteButton1).not.toBeDisabled();
});
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
@ -497,25 +402,11 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
});
const memberRow = page.getByRole('row', { name: memberUser.email });
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Test that the new owner can access organisation settings
await apiSignin({

View File

@ -1,12 +1,9 @@
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
@ -124,7 +121,7 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
await expect(page.getByText('404 not found')).toBeVisible();
});
test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page }) => {
test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => {
const { user, team } = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({
@ -156,53 +153,6 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeDisabled();
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
});
test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page }) => {
const { user, team } = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({
title: 'Personal direct template link',
userId: user.id,
teamId: team.id,
internalVersion: 2,
createTemplateOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: [],
}),
},
});
const directTemplatePath = formatDirectTemplatePath(
directTemplateWithAuth.directLink?.token || '',
);
await page.goto(directTemplatePath);
await expect(page.getByText('Authentication required')).toBeVisible();
await apiSignin({
page,
email: user.email,
});
await page.goto(directTemplatePath);
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByLabel('Your Email')).not.toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
});
test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => {
@ -225,9 +175,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
@ -236,173 +183,3 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
// Add a longer waiting period to ensure document status is updated
await page.waitForTimeout(3000);
});
test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with next signer dictation', async ({
page,
}) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
// Should be visible to team members.
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
});
await prisma.documentMeta.update({
where: {
id: template.documentMetaId,
},
data: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
email: originalSecondSignerEmail,
name: originalName,
token: Math.random().toString().slice(2, 7),
role: RecipientRole.SIGNER,
},
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.waitForTimeout(100);
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByText('Next Recipient Name')).toBeVisible();
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
expect(nextRecipientNameInputValue).toBe(originalName);
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
const newName = 'Hello';
const newSecondSignerEmail = seedTestEmail();
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
await page.getByLabel('Next Recipient Name').fill(newName);
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
const createdEnvelopeRecipients = await prisma.recipient.findMany({
where: {
envelope: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
});
test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with next signer dictation', async ({
page,
}) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
// Should be visible to team members.
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
internalVersion: 2,
});
await prisma.documentMeta.update({
where: {
id: template.documentMetaId,
},
data: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
email: originalSecondSignerEmail,
name: originalName,
token: Math.random().toString().slice(2, 7),
role: RecipientRole.SIGNER,
},
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Team direct template link 1' })).toBeVisible();
await page.waitForTimeout(100);
await page.getByRole('button', { name: 'Complete' }).click();
const currentName = 'John Doe';
const currentEmail = seedTestEmail();
await page.getByPlaceholder('Enter Your Name').fill(currentName);
await page.getByPlaceholder('Enter Your Email').fill(currentEmail);
await expect(page.getByText('Next Recipient Name')).toBeVisible();
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
expect(nextRecipientNameInputValue).toBe(originalName);
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
const newName = 'Hello';
const newSecondSignerEmail = seedTestEmail();
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
await page.getByLabel('Next Recipient Name').fill(newName);
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
const createdEnvelopeRecipients = await prisma.recipient.findMany({
where: {
envelope: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
});

View File

@ -14,5 +14,3 @@ export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED
export const API_V2_BETA_URL = '/api/v2-beta';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
export const IS_ENVELOPES_ENABLED = env('NEXT_PUBLIC_FEATURE_ENVELOPES_ENABLED') === 'true';

View File

@ -97,9 +97,7 @@ export const completeDocumentWithToken = async ({
}
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({
token: recipient.token,
});
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
if (!isRecipientsTurn) {
throw new Error(
@ -153,18 +151,6 @@ export const completeDocumentWithToken = async ({
}),
});
const envelopeForFailure = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.RECIPIENT_AUTHENTICATION_FAILED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeForFailure)),
userId: envelope.userId,
teamId: envelope.teamId,
});
throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, {
message: 'Invalid 2FA authentication',
});
@ -219,18 +205,6 @@ export const completeDocumentWithToken = async ({
});
});
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
userId: envelope.userId,
teamId: envelope.teamId,
});
await jobs.triggerJob({
name: 'send.recipient.signed.email',
payload: {

View File

@ -1,5 +1,7 @@
import { EnvelopeType, TeamMemberRole } from '@prisma/client';
import type { Prisma, User } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@ -213,14 +215,13 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
teamId,
deletedAt: null,
folderId,
};
let notSignedCountsGroupByArgs = null;
@ -264,16 +265,8 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
ownerCountsWhereInput = {
...ownerCountsWhereInput,
AND: [
...(Array.isArray(visibilityFiltersWhereInput.AND)
? visibilityFiltersWhereInput.AND
: visibilityFiltersWhereInput.AND
? [visibilityFiltersWhereInput.AND]
: []),
searchFilter,
rootPageFilter,
folderId ? { folderId } : {},
],
...visibilityFiltersWhereInput,
...searchFilter,
};
if (teamEmail) {
@ -292,7 +285,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
],
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
};
notSignedCountsGroupByArgs = {
@ -304,6 +296,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
folderId,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
@ -313,7 +306,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
@ -326,6 +318,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
folderId,
OR: [
{
status: ExtendedDocumentStatus.PENDING,
@ -349,7 +342,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
}

View File

@ -7,7 +7,6 @@ import {
OrganisationType,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
@ -25,16 +24,11 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ResendDocumentOptions = {
id: EnvelopeIdOptions;
@ -236,11 +230,4 @@ export const resendDocument = async ({
);
}),
);
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
};

View File

@ -1,4 +1,5 @@
import { EnvelopeType, ReadStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { EnvelopeType, ReadStatus, SendStatus } from '@prisma/client';
import { WebhookTriggerEvents } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@ -65,13 +66,6 @@ export const viewedDocument = async ({
}),
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_VIEWED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
// Early return if already opened.
if (recipient.readStatus === ReadStatus.OPENED) {
return;

View File

@ -386,13 +386,6 @@ export const createEnvelope = async ({
userId,
teamId,
});
} else if (type === EnvelopeType.TEMPLATE) {
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
}
return createdEnvelope;

View File

@ -1,11 +1,10 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@ -99,28 +98,14 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
// Currently not using this since for direct templates "User" access means they just need to be
// logged in.
// const documentAccessValid = await isRecipientAuthorized({
// type: 'ACCESS',
// documentAuthOptions: envelope.authOptions,
// recipient,
// userId,
// authOptions: accessAuth,
// });
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
const documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: envelope.authOptions,
recipient,
userId,
authOptions: accessAuth,
});
// Ensure typesafety when we add more options.
const documentAccessValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(userId))
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
.exhaustive(),
);
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',

View File

@ -54,3 +54,54 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
directLink: {
enabled: true,
token,
},
status: DocumentStatus.DRAFT,
},
include: {
recipients: {
where: {
token,
},
},
directLink: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const recipient = envelope.recipients.find(
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const recipientUserAccount = await prisma.user.findFirst({
where: {
email: recipient.email.toLowerCase(),
},
select: {
id: true,
},
});
return {
recipientEmail: recipient.email,
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};

View File

@ -1,5 +1,6 @@
import type { DocumentMeta, DocumentVisibility, Prisma, TemplateType } from '@prisma/client';
import { DocumentStatus, EnvelopeType, FolderType, WebhookTriggerEvents } from '@prisma/client';
import { EnvelopeType, FolderType } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -11,14 +12,9 @@ import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getEnvelopeWhereInput } from './get-envelope-by-id';
export type UpdateEnvelopeOptions = {
@ -343,22 +339,6 @@ export const updateEnvelope = async ({
});
}
if (envelope.type === EnvelopeType.TEMPLATE) {
const envelopeWithRelations = await tx.envelope.findUniqueOrThrow({
where: { id: updatedEnvelope.id },
include: { documentMeta: true, recipients: true },
});
void triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_UPDATED,
data: ZWebhookDocumentSchema.parse(
mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations),
),
userId,
teamId,
});
}
return updatedEnvelope;
});
};

View File

@ -11,7 +11,7 @@ export type GetFieldByIdOptions = {
userId: number;
teamId: number;
fieldId: number;
envelopeType: EnvelopeType;
envelopeType?: EnvelopeType;
};
export const getFieldById = async ({
@ -41,7 +41,7 @@ export const getFieldById = async ({
type: 'envelopeId',
id: field.envelopeId,
},
type: envelopeType,
type: envelopeType ?? null,
userId,
teamId,
});

View File

@ -10,18 +10,21 @@ import {
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateDocumentFieldsOptions {
export interface UpdateEnvelopeFieldsOptions {
userId: number;
teamId: number;
documentId: number;
id: EnvelopeIdOptions;
type?: EnvelopeType | null; // Only used to enforce the type.
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
envelopeItemId?: string;
pageX?: number;
pageY?: number;
width?: number;
@ -31,19 +34,17 @@ export interface UpdateDocumentFieldsOptions {
requestMetadata: ApiRequestMetadata;
}
export const updateDocumentFields = async ({
export const updateEnvelopeFields = async ({
userId,
teamId,
documentId,
id,
type = null,
fields,
requestMetadata,
}: UpdateDocumentFieldsOptions) => {
}: UpdateEnvelopeFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
id,
type,
userId,
teamId,
});
@ -53,18 +54,19 @@ export const updateDocumentFields = async ({
include: {
recipients: true,
fields: true,
envelopeItems: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -96,6 +98,29 @@ export const updateDocumentFields = async ({
});
}
const fieldType = field.type || originalField.type;
const fieldMetaType = field.fieldMeta?.type || originalField.fieldMeta?.type;
// Not going to mess with V1 envelopes.
if (
envelope.internalVersion === 2 &&
fieldMetaType &&
fieldMetaType.toLowerCase() !== fieldType.toLowerCase()
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Field meta type does not match the field type',
});
}
if (
field.envelopeItemId &&
!envelope.envelopeItems.some((item) => item.id === field.envelopeItemId)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope item not found',
});
}
return {
originalField,
updateData: field,
@ -118,27 +143,30 @@ export const updateDocumentFields = async ({
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
envelopeItemId: updateData.envelopeItemId,
},
});
const changes = diffFieldChanges(originalField, updatedField);
// Handle field updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: recipientEmail,
fieldRecipientId: updatedField.recipientId,
fieldType: updatedField.type,
changes,
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
const changes = diffFieldChanges(originalField, updatedField);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: updatedField.secondaryId,
fieldRecipientEmail: recipientEmail,
fieldRecipientId: updatedField.recipientId,
fieldType: updatedField.type,
changes,
},
}),
});
}
}
return updatedField;

View File

@ -1,116 +0,0 @@
import { EnvelopeType, type FieldType } from '@prisma/client';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateTemplateFieldsOptions {
userId: number;
teamId: number;
templateId: number;
fields: {
id: number;
type?: FieldType;
pageNumber?: number;
pageX?: number;
pageY?: number;
width?: number;
height?: number;
fieldMeta?: TFieldMetaSchema;
}[];
}
export const updateTemplateFields = async ({
userId,
teamId,
templateId,
fields,
}: UpdateTemplateFieldsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
fields: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const fieldsToUpdate = fields.map((field) => {
const originalField = envelope.fields.find((existingField) => existingField.id === field.id);
if (!originalField) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Field with id ${field.id} not found`,
});
}
const recipient = envelope.recipients.find(
(recipient) => recipient.id === originalField.recipientId,
);
// Each field MUST have a recipient associated with it.
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient attached to field ${field.id} not found`,
});
}
// Check whether the recipient associated with the field can be modified.
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Cannot modify a field where the recipient has already interacted with the document',
});
}
return {
updateData: field,
};
});
const updatedFields = await prisma.$transaction(async (tx) => {
return await Promise.all(
fieldsToUpdate.map(async ({ updateData }) => {
const updatedField = await tx.field.update({
where: {
id: updateData.id,
},
data: {
type: updateData.type,
page: updateData.pageNumber,
positionX: updateData.pageX,
positionY: updateData.pageY,
width: updateData.width,
height: updateData.height,
fieldMeta: updateData.fieldMeta,
},
});
return updatedField;
}),
);
});
return {
fields: updatedFields.map((field) => mapFieldToLegacyField(field, envelope)),
};
};

View File

@ -15,7 +15,7 @@ import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface CreateDocumentRecipientsOptions {
export interface CreateEnvelopeRecipientsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
@ -30,16 +30,16 @@ export interface CreateDocumentRecipientsOptions {
requestMetadata: ApiRequestMetadata;
}
export const createDocumentRecipients = async ({
export const createEnvelopeRecipients = async ({
userId,
teamId,
id,
recipients: recipientsToCreate,
requestMetadata,
}: CreateDocumentRecipientsOptions) => {
}: CreateEnvelopeRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
type: null,
userId,
teamId,
});
@ -62,13 +62,13 @@ export const createDocumentRecipients = async ({
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -112,21 +112,23 @@ export const createDocumentRecipients = async ({
});
// Handle recipient created audit log.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: createdRecipient.email,
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: createdRecipient.email,
recipientName: createdRecipient.name,
recipientId: createdRecipient.id,
recipientRole: createdRecipient.role,
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
},
}),
});
}
return createdRecipient;
}),

View File

@ -1,115 +0,0 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface CreateTemplateRecipientsOptions {
userId: number;
teamId: number;
templateId: number;
recipients: {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
export const createTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients: recipientsToCreate,
}: CreateTemplateRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const template = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipientsToCreate.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
}));
const createdRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const createdRecipient = await tx.recipient.create({
data: {
envelopeId: template.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
});
return createdRecipient;
}),
);
});
return {
recipients: createdRecipients.map((recipient) =>
mapRecipientToLegacyRecipient(recipient, template),
),
};
};

View File

@ -14,26 +14,27 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { canRecipientBeModified } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteDocumentRecipientOptions {
export interface DeleteEnvelopeRecipientOptions {
userId: number;
teamId: number;
recipientId: number;
requestMetadata: ApiRequestMetadata;
}
export const deleteDocumentRecipient = async ({
export const deleteEnvelopeRecipient = async ({
userId,
teamId,
recipientId,
requestMetadata,
}: DeleteDocumentRecipientOptions) => {
}: DeleteEnvelopeRecipientOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
id: recipientId,
@ -48,6 +49,9 @@ export const deleteDocumentRecipient = async ({
where: {
id: recipientId,
},
include: {
fields: true,
},
},
},
});
@ -89,24 +93,43 @@ export const deleteDocumentRecipient = async ({
});
}
const deletedRecipient = await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
recipientId: recipientToDelete.id,
recipientRole: recipientToDelete.role,
},
}),
if (!canRecipientBeModified(recipientToDelete, recipientToDelete.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient has already interacted with the document.',
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelope.id,
},
type: null,
userId,
teamId,
});
const deletedRecipient = await prisma.$transaction(async (tx) => {
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
recipientId: recipientToDelete.id,
recipientRole: recipientToDelete.role,
},
}),
});
}
return await tx.recipient.delete({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
});
});
@ -116,7 +139,11 @@ export const deleteDocumentRecipient = async ({
).recipientRemoved;
// Send email to deleted recipient.
if (recipientToDelete.sendStatus === SendStatus.SENT && isRecipientRemovedEmailEnabled) {
if (
recipientToDelete.sendStatus === SendStatus.SENT &&
isRecipientRemovedEmailEnabled &&
envelope.type === EnvelopeType.DOCUMENT
) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {

View File

@ -1,58 +0,0 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteTemplateRecipientOptions {
userId: number;
teamId: number;
recipientId: number;
}
export const deleteTemplateRecipient = async ({
userId,
teamId,
recipientId,
}: DeleteTemplateRecipientOptions): Promise<void> => {
const recipientToDelete = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelope: {
type: EnvelopeType.TEMPLATE,
team: buildTeamWhereQuery({ teamId, userId }),
},
},
});
if (!recipientToDelete) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: recipientToDelete.envelopeId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
if (!recipientToDelete || recipientToDelete.id !== recipientId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
await prisma.recipient.delete({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
});
};

View File

@ -1,5 +1,4 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@ -16,29 +15,38 @@ import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { extractLegacyIds } from '../../universal/id';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateDocumentRecipientsOptions {
export interface UpdateEnvelopeRecipientsOptions {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
recipients: RecipientData[];
recipients: {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
requestMetadata: ApiRequestMetadata;
}
export const updateDocumentRecipients = async ({
export const updateEnvelopeRecipients = async ({
userId,
teamId,
id,
recipients,
requestMetadata,
}: UpdateDocumentRecipientsOptions) => {
}: UpdateEnvelopeRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
type: null,
userId,
teamId,
});
@ -62,13 +70,13 @@ export const updateDocumentRecipients = async ({
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
message: 'Envelope not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
message: 'Envelope already complete',
});
}
@ -160,24 +168,26 @@ export const updateDocumentRecipients = async ({
});
}
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
// Handle recipient updated audit log.
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientId: updatedRecipient.id,
recipientRole: updatedRecipient.role,
changes,
},
}),
});
if (envelope.type === EnvelopeType.DOCUMENT) {
const changes = diffRecipientChanges(originalRecipient, updatedRecipient);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientEmail: updatedRecipient.email,
recipientName: updatedRecipient.name,
recipientId: updatedRecipient.id,
recipientRole: updatedRecipient.role,
changes,
},
}),
});
}
}
return updatedRecipient;
@ -188,19 +198,8 @@ export const updateDocumentRecipients = async ({
return {
recipients: updatedRecipients.map((recipient) => ({
...recipient,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
templateId: null,
...extractLegacyIds(envelope),
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
})),
};
};
type RecipientData = {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};

View File

@ -1,168 +0,0 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateTemplateRecipientsOptions {
userId: number;
teamId: number;
templateId: number;
recipients: {
id: number;
email?: string;
name?: string;
role?: RecipientRole;
signingOrder?: number | null;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
}
export const updateTemplateRecipients = async ({
userId,
teamId,
templateId,
recipients,
}: UpdateTemplateRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
userId,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Template not found',
});
}
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth && !envelope.team.organisation.organisationClaim.flags.cfr21) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = envelope.recipients.find(
(existingRecipient) => existingRecipient.id === recipient.id,
);
if (!originalRecipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient with id ${recipient.id} not found`,
});
}
return {
originalRecipient,
recipientUpdateData: recipient,
};
});
const updatedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
recipientsToUpdate.map(async ({ originalRecipient, recipientUpdateData }) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
if (
recipientUpdateData.actionAuth !== undefined ||
recipientUpdateData.accessAuth !== undefined
) {
authOptions = createRecipientAuthOptions({
accessAuth: recipientUpdateData.accessAuth || authOptions.accessAuth,
actionAuth: recipientUpdateData.actionAuth || authOptions.actionAuth,
});
}
const mergedRecipient = {
...originalRecipient,
...recipientUpdateData,
};
const updatedRecipient = await tx.recipient.update({
where: {
id: originalRecipient.id,
envelopeId: envelope.id,
},
data: {
name: mergedRecipient.name,
email: mergedRecipient.email,
role: mergedRecipient.role,
signingOrder: mergedRecipient.signingOrder,
envelopeId: envelope.id,
sendStatus:
mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
mergedRecipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
authOptions,
},
include: {
fields: true,
},
});
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
originalRecipient.role !== updatedRecipient.role &&
(updatedRecipient.role === RecipientRole.CC ||
updatedRecipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
recipientId: updatedRecipient.id,
},
});
}
return updatedRecipient;
}),
);
});
return {
recipients: updatedRecipients.map((recipient) => ({
...recipient,
documentId: null,
templateId: mapSecondaryIdToTemplateId(envelope.secondaryId),
fields: recipient.fields.map((field) => mapFieldToLegacyField(field, envelope)),
})),
};
};

View File

@ -3,7 +3,6 @@ import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
EnvelopeType,
@ -27,7 +26,7 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta';
@ -69,10 +68,6 @@ export type CreateDocumentFromDirectTemplateOptions = {
name?: string;
email: string;
};
nextSigner?: {
email: string;
name: string;
};
};
type CreatedDirectRecipientField = {
@ -97,7 +92,6 @@ export const createDocumentFromDirectTemplate = async ({
directTemplateExternalId,
signedFieldValues,
templateUpdatedAt,
nextSigner,
requestMetadata,
user,
}: CreateDocumentFromDirectTemplateOptions): Promise<TCreateDocumentFromDirectTemplateResponse> => {
@ -134,17 +128,6 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
}
if (
nextSigner &&
(!directTemplateEnvelope.documentMeta?.allowDictateNextSigner ||
directTemplateEnvelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'You need to enable allowDictateNextSigner and sequential signing to dictate the next signer',
});
}
const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId(
directTemplateEnvelope.secondaryId,
);
@ -647,77 +630,6 @@ export const createDocumentFromDirectTemplate = async ({
}),
];
if (nextSigner) {
const pendingRecipients = await tx.recipient.findMany({
select: {
id: true,
signingOrder: true,
name: true,
email: true,
role: true,
},
where: {
envelopeId: createdEnvelope.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
role: {
not: RecipientRole.CC,
},
},
// Composite sort so our next recipient is always the one with the lowest signing order or id
// if there is a tie.
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
const nextRecipient = pendingRecipients[0];
if (nextRecipient) {
auditLogsToCreate.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: createdEnvelope.id,
user: {
name: user?.name || directRecipientName || '',
email: user?.email || directRecipientEmail,
},
metadata: requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
);
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
}
}
await tx.documentAuditLog.createMany({
data: auditLogsToCreate,
});

View File

@ -725,13 +725,6 @@ export const createDocumentFromTemplate = async ({
teamId,
});
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_USED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
return envelope;
});
};

View File

@ -1,14 +1,9 @@
import { EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteTemplateOptions = {
id: EnvelopeIdOptions;
@ -24,18 +19,6 @@ export const deleteTemplate = async ({ id, userId, teamId }: DeleteTemplateOptio
teamId,
});
const templateToDelete = await prisma.envelope.findUniqueOrThrow({
where: envelopeWhereInput,
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_DELETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(templateToDelete)),
userId,
teamId,
});
return await prisma.envelope.delete({
where: envelopeWhereInput,
});

View File

@ -480,198 +480,5 @@ export const generateSampleWebhookPayload = (
};
}
if (event === WebhookTriggerEvents.DOCUMENT_VIEWED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_DOWNLOADED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.COMPLETED,
completedAt: now,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_REMINDER_SENT) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.recipients[0],
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.RECIPIENT_AUTHENTICATION_FAILED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_CREATED) {
return {
event,
payload: {
...basePayload,
title: 'My Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_UPDATED) {
return {
event,
payload: {
...basePayload,
title: 'My Updated Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_DELETED) {
return {
event,
payload: {
...basePayload,
title: 'Deleted Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_USED) {
return {
event,
payload: {
...basePayload,
title: 'Document from Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
throw new Error(`Unsupported event type: ${event}`);
};

View File

@ -37,11 +37,8 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
userId: true,
teamId: true,
folderId: true,
templateId: true,
}).extend({
templateId: z
.number()
.nullish()
.describe('The ID of the template that the document was created from, if any.'),
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,

View File

@ -50,6 +50,11 @@ export const ZFieldSchema = FieldSchema.pick({
templateId: z.number().nullish(),
});
export const ZEnvelopeFieldSchema = ZFieldSchema.omit({
documentId: true,
templateId: true,
});
export const ZFieldPageNumberSchema = z
.number()
.min(1)
@ -69,6 +74,30 @@ export const ZFieldWidthSchema = z.number().min(1).describe('The width of the fi
export const ZFieldHeightSchema = z.number().min(1).describe('The height of the field.');
export const ZClampedFieldPageXSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based X coordinate where the field will be placed.');
export const ZClampedFieldPageYSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based Y coordinate where the field will be placed.');
export const ZClampedFieldWidthSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based width of the field on the page.');
export const ZClampedFieldHeightSchema = z
.number()
.min(0)
.max(100)
.describe('The percentage based height of the field on the page.');
// ---------------------------------------------
const PrismaDecimalSchema = z.preprocess(

View File

@ -95,3 +95,18 @@ export const ZRecipientManySchema = RecipientSchema.pick({
documentId: z.number().nullish(),
templateId: z.number().nullish(),
});
export const ZEnvelopeRecipientSchema = ZRecipientSchema.omit({
documentId: true,
templateId: true,
});
export const ZEnvelopeRecipientLiteSchema = ZRecipientLiteSchema.omit({
documentId: true,
templateId: true,
});
export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
documentId: true,
templateId: true,
});

View File

@ -1,17 +0,0 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_VIEWED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_RECIPIENT_COMPLETED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_DOWNLOADED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_REMINDER_SENT';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_CREATED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_UPDATED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_DELETED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_USED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'RECIPIENT_AUTHENTICATION_FAILED';

View File

@ -172,15 +172,6 @@ enum WebhookTriggerEvents {
DOCUMENT_COMPLETED
DOCUMENT_REJECTED
DOCUMENT_CANCELLED
DOCUMENT_VIEWED
DOCUMENT_RECIPIENT_COMPLETED
DOCUMENT_DOWNLOADED
DOCUMENT_REMINDER_SENT
TEMPLATE_CREATED
TEMPLATE_UPDATED
TEMPLATE_DELETED
TEMPLATE_USED
RECIPIENT_AUTHENTICATION_FAILED
}
model Webhook {

View File

@ -28,7 +28,6 @@ type SeedTemplateOptions = {
title?: string;
userId: number;
teamId: number;
internalVersion?: 1 | 2;
createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
};
@ -168,7 +167,7 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
data: {
id: prefixedId('envelope'),
secondaryId: templateId.formattedTemplateId,
internalVersion: options.internalVersion ?? 1,
internalVersion: 1,
type: EnvelopeType.TEMPLATE,
title,
envelopeItems: {
@ -185,7 +184,6 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
teamId,
recipients: {
create: {
signingOrder: 1,
email: DIRECT_TEMPLATE_RECIPIENT_EMAIL,
name: DIRECT_TEMPLATE_RECIPIENT_NAME,
token: Math.random().toString().slice(2, 7),

View File

@ -39,11 +39,6 @@ export const getAdminOrganisation = async ({ organisationId }: GetOrganisationOp
teams: true,
members: {
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
user: {
select: {
id: true,

View File

@ -3,8 +3,6 @@ import { z } from 'zod';
import { ZOrganisationSchema } from '@documenso/lib/types/organisation';
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationGroupMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupMemberSchema';
import OrganisationGroupSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
@ -32,18 +30,6 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({
email: true,
name: true,
}),
organisationGroupMembers: z.array(
OrganisationGroupMemberSchema.pick({
id: true,
groupId: true,
}).extend({
group: OrganisationGroupSchema.pick({
id: true,
type: true,
organisationRole: true,
}),
}),
),
}).array(),
subscription: SubscriptionSchema.nullable(),
organisationClaim: OrganisationClaimSchema,

View File

@ -17,7 +17,6 @@ import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
import { resealDocumentRoute } from './reseal-document';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import { updateAdminOrganisationRoute } from './update-admin-organisation';
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
import { updateRecipientRoute } from './update-recipient';
import { updateSiteSettingRoute } from './update-site-setting';
import { updateSubscriptionClaimRoute } from './update-subscription-claim';
@ -32,7 +31,6 @@ export const adminRouter = router({
},
organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute,
updateRole: updateOrganisationMemberRoleRoute,
},
claims: {
find: findSubscriptionClaimsRoute,

View File

@ -1,220 +0,0 @@
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZUpdateOrganisationMemberRoleRequestSchema,
ZUpdateOrganisationMemberRoleResponseSchema,
} from './update-organisation-member-role.types';
/**
* Admin mutation to update organisation member role or transfer ownership.
*
* This mutation handles two scenarios:
* 1. When role='OWNER': Transfers organisation ownership and promotes to ADMIN
* 2. When role=ADMIN/MANAGER/MEMBER: Updates group membership
*
* Admin privileges bypass normal hierarchy restrictions.
*/
export const updateOrganisationMemberRoleRoute = adminProcedure
.input(ZUpdateOrganisationMemberRoleRequestSchema)
.output(ZUpdateOrganisationMemberRoleResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, userId, role } = input;
ctx.logger.info({
input: {
organisationId,
userId,
role,
},
});
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
include: {
groups: {
where: {
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
},
members: {
where: {
userId,
},
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
const [member] = organisation.members;
if (!member) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User is not a member of this organisation',
});
}
const currentOrganisationRole = getHighestOrganisationRoleInGroup(
member.organisationGroupMembers.flatMap((member) => member.group),
);
if (role === 'OWNER') {
if (organisation.ownerUserId === userId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'User is already the owner of this organisation',
});
}
const currentMemberGroup = organisation.groups.find(
(group) => group.organisationRole === currentOrganisationRole,
);
const adminGroup = organisation.groups.find(
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
);
if (!currentMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
role: currentOrganisationRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current member group not found',
});
}
if (!adminGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
targetRole: 'ADMIN',
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Admin group not found',
});
}
await prisma.$transaction(async (tx) => {
await tx.organisation.update({
where: {
id: organisationId,
},
data: {
ownerUserId: userId,
},
});
if (currentOrganisationRole !== OrganisationMemberRole.ADMIN) {
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: member.id,
groupId: currentMemberGroup.id,
},
},
});
await tx.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
organisationMemberId: member.id,
groupId: adminGroup.id,
},
});
}
});
return;
}
const targetRole = role as OrganisationMemberRole;
if (currentOrganisationRole === targetRole) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'User already has this role',
});
}
if (userId === organisation.ownerUserId && targetRole !== OrganisationMemberRole.ADMIN) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Organisation owner must be an admin. Transfer ownership first.',
});
}
const currentMemberGroup = organisation.groups.find(
(group) => group.organisationRole === currentOrganisationRole,
);
const newMemberGroup = organisation.groups.find(
(group) => group.organisationRole === targetRole,
);
if (!currentMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
role: currentOrganisationRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current member group not found',
});
}
if (!newMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
targetRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'New member group not found',
});
}
await prisma.$transaction(async (tx) => {
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: member.id,
groupId: currentMemberGroup.id,
},
},
});
await tx.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
organisationMemberId: member.id,
groupId: newMemberGroup.id,
},
});
});
});

View File

@ -1,30 +0,0 @@
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
/**
* Admin-only role selection that includes OWNER as a special case.
* OWNER is not a database role but triggers ownership transfer.
*/
export const ZAdminRoleSelection = z.enum([
'OWNER',
OrganisationMemberRole.ADMIN,
OrganisationMemberRole.MANAGER,
OrganisationMemberRole.MEMBER,
]);
export type TAdminRoleSelection = z.infer<typeof ZAdminRoleSelection>;
export const ZUpdateOrganisationMemberRoleRequestSchema = z.object({
organisationId: z.string().min(1),
userId: z.number().min(1),
role: ZAdminRoleSelection,
});
export const ZUpdateOrganisationMemberRoleResponseSchema = z.void();
export type TUpdateOrganisationMemberRoleRequest = z.infer<
typeof ZUpdateOrganisationMemberRoleRequestSchema
>;
export type TUpdateOrganisationMemberRoleResponse = z.infer<
typeof ZUpdateOrganisationMemberRoleResponseSchema
>;

View File

@ -1,13 +1,8 @@
import type { DocumentData } from '@prisma/client';
import { DocumentDataType, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
import { DocumentDataType, EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '@documenso/lib/types/webhook-payload';
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
@ -81,13 +76,6 @@ export const downloadDocumentRoute = authenticatedProcedure
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
void triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_DOWNLOADED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
return {
downloadUrl: url,
filename,

View File

@ -13,11 +13,21 @@ import {
} from './create-envelope-items.types';
export const createEnvelopeItemsRoute = authenticatedProcedure
// Todo: Envelopes - Pending direct uploads
// .meta({
// openapi: {
// method: 'POST',
// path: '/envelope/item/create-many',
// summary: 'Create envelope items',
// description: 'Create multiple envelope items for an envelope',
// tags: ['Envelope Item'],
// },
// })
.input(ZCreateEnvelopeItemsRequestSchema)
.output(ZCreateEnvelopeItemsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { envelopeId, items } = input;
const { envelopeId, data: items } = input;
ctx.logger.info({
input: {

View File

@ -7,7 +7,7 @@ import { ZDocumentTitleSchema } from '../document-router/schema';
export const ZCreateEnvelopeItemsRequestSchema = z.object({
envelopeId: z.string(),
items: z
data: z
.object({
title: ZDocumentTitleSchema,
documentDataId: z.string(),

View File

@ -9,6 +9,15 @@ import {
} from './create-envelope.types';
export const createEnvelopeRoute = authenticatedProcedure
// Todo: Envelopes - Pending direct uploads
// .meta({
// openapi: {
// method: 'POST',
// path: '/envelope/create',
// summary: 'Create envelope',
// tags: ['Envelope'],
// },
// })
.input(ZCreateEnvelopeRequestSchema)
.output(ZCreateEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -24,16 +24,6 @@ import {
} from '../document-router/schema';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
// Currently not in use until we allow passthrough documents on create.
// export const createEnvelopeMeta: TrpcRouteMeta = {
// openapi: {
// method: 'POST',
// path: '/envelope/create',
// summary: 'Create envelope',
// tags: ['Envelope'],
// },
// };
export const ZCreateEnvelopeRequestSchema = z.object({
title: ZDocumentTitleSchema,
type: z.nativeEnum(EnvelopeType),

View File

@ -12,6 +12,15 @@ import {
} from './delete-envelope-item.types';
export const deleteEnvelopeItemRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/item/delete',
summary: 'Delete envelope item',
description: 'Delete an envelope item from an envelope',
tags: ['Envelope Item'],
},
})
.input(ZDeleteEnvelopeItemRequestSchema)
.output(ZDeleteEnvelopeItemResponseSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -1,8 +1,10 @@
import { EnvelopeType } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
@ -11,12 +13,19 @@ import {
} from './delete-envelope.types';
export const deleteEnvelopeRoute = authenticatedProcedure
// .meta(deleteEnvelopeMeta)
.meta({
openapi: {
method: 'POST',
path: '/envelope/delete',
summary: 'Delete envelope',
tags: ['Envelope'],
},
})
.input(ZDeleteEnvelopeRequestSchema)
.output(ZDeleteEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { envelopeId, envelopeType } = input;
const { envelopeId } = input;
ctx.logger.info({
input: {
@ -24,7 +33,22 @@ export const deleteEnvelopeRoute = authenticatedProcedure
},
});
await match(envelopeType)
const unsafeEnvelope = await prisma.envelope.findUnique({
where: {
id: envelopeId,
},
select: {
type: true,
},
});
if (!unsafeEnvelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
await match(unsafeEnvelope.type)
.with(EnvelopeType.DOCUMENT, async () =>
deleteDocument({
userId: ctx.user.id,

View File

@ -1,18 +1,7 @@
import { EnvelopeType } from '@prisma/client';
import { z } from 'zod';
// export const deleteEnvelopeMeta: TrpcRouteMeta = {
// openapi: {
// method: 'POST',
// path: '/envelope/delete',
// summary: 'Delete envelope',
// tags: ['Envelope'],
// },
// };
export const ZDeleteEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
envelopeType: z.nativeEnum(EnvelopeType),
});
export const ZDeleteEnvelopeResponseSchema = z.void();

View File

@ -8,7 +8,15 @@ import {
} from './distribute-envelope.types';
export const distributeEnvelopeRoute = authenticatedProcedure
// .meta(distributeEnvelopeMeta)
.meta({
openapi: {
method: 'POST',
path: '/envelope/distribute',
summary: 'Distribute envelope',
description: 'Send the envelope to recipients based on your distribution method',
tags: ['Envelope'],
},
})
.input(ZDistributeEnvelopeRequestSchema)
.output(ZDistributeEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -2,16 +2,6 @@ import { z } from 'zod';
import { ZDocumentMetaUpdateSchema } from '@documenso/lib/types/document-meta';
// export const distributeEnvelopeMeta: TrpcRouteMeta = {
// openapi: {
// method: 'POST',
// path: '/envelope/distribute',
// summary: 'Distribute envelope',
// description: 'Send the document out to recipients based on your distribution method',
// tags: ['Envelope'],
// },
// };
export const ZDistributeEnvelopeRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope to send.'),
meta: ZDocumentMetaUpdateSchema.pick({

View File

@ -7,6 +7,15 @@ import {
} from './duplicate-envelope.types';
export const duplicateEnvelopeRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/duplicate',
summary: 'Duplicate envelope',
description: 'Duplicate an envelope with all its settings',
tags: ['Envelope'],
},
})
.input(ZDuplicateEnvelopeRequestSchema)
.output(ZDuplicateEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -0,0 +1,41 @@
import { createEnvelopeFields } from '@documenso/lib/server-only/field/create-envelope-fields';
import { authenticatedProcedure } from '../../trpc';
import {
ZCreateEnvelopeFieldsRequestSchema,
ZCreateEnvelopeFieldsResponseSchema,
} from './create-envelope-fields.types';
export const createEnvelopeFieldsRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/field/create-many',
summary: 'Create envelope fields',
description: 'Create multiple fields for an envelope',
tags: ['Envelope Fields'],
},
})
.input(ZCreateEnvelopeFieldsRequestSchema)
.output(ZCreateEnvelopeFieldsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { envelopeId, data: fields } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
return await createEnvelopeFields({
userId: user.id,
teamId,
id: {
type: 'envelopeId',
id: envelopeId,
},
fields,
requestMetadata: metadata,
});
});

View File

@ -0,0 +1,40 @@
import { z } from 'zod';
import {
ZClampedFieldHeightSchema,
ZClampedFieldPageXSchema,
ZClampedFieldPageYSchema,
ZClampedFieldWidthSchema,
ZFieldPageNumberSchema,
ZFieldSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
const ZCreateFieldSchema = ZFieldAndMetaSchema.and(
z.object({
recipientId: z.number().describe('The ID of the recipient to create the field for'),
envelopeItemId: z
.string()
.optional()
.describe(
'The ID of the envelope item to put the field on. If not provided, field will be placed on the first item.',
),
pageNumber: ZFieldPageNumberSchema,
pageX: ZClampedFieldPageXSchema,
pageY: ZClampedFieldPageYSchema,
width: ZClampedFieldWidthSchema,
height: ZClampedFieldHeightSchema,
}),
);
export const ZCreateEnvelopeFieldsRequestSchema = z.object({
envelopeId: z.string(),
data: ZCreateFieldSchema.array(),
});
export const ZCreateEnvelopeFieldsResponseSchema = z.object({
fields: z.array(ZFieldSchema),
});
export type TCreateEnvelopeFieldsRequest = z.infer<typeof ZCreateEnvelopeFieldsRequestSchema>;
export type TCreateEnvelopeFieldsResponse = z.infer<typeof ZCreateEnvelopeFieldsResponseSchema>;

View File

@ -0,0 +1,125 @@
import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../../trpc';
import {
ZDeleteEnvelopeFieldRequestSchema,
ZDeleteEnvelopeFieldResponseSchema,
} from './delete-envelope-field.types';
export const deleteEnvelopeFieldRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/field/delete',
summary: 'Delete envelope field',
description: 'Delete an envelope field',
tags: ['Envelope Field'],
},
})
.input(ZDeleteEnvelopeFieldRequestSchema)
.output(ZDeleteEnvelopeFieldResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { fieldId } = input;
ctx.logger.info({
input: {
fieldId,
},
});
const unsafeField = await prisma.field.findUnique({
where: {
id: fieldId,
},
select: {
envelopeId: true,
},
});
if (!unsafeField) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field not found',
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: unsafeField.envelopeId,
},
type: null,
userId: user.id,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: {
include: {
fields: true,
},
},
},
});
const recipientWithFields = envelope?.recipients.find((recipient) =>
recipient.fields.some((field) => field.id === fieldId),
);
const fieldToDelete = recipientWithFields?.fields.find((field) => field.id === fieldId);
if (!envelope || !recipientWithFields || !fieldToDelete) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Field not found',
});
}
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete',
});
}
// Check whether the recipient associated with the field can have new fields created.
if (!canRecipientFieldsBeModified(recipientWithFields, recipientWithFields.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient has already interacted with the document.',
});
}
await prisma.$transaction(async (tx) => {
const deletedField = await tx.field.delete({
where: {
id: fieldToDelete.id,
envelopeId: envelope.id,
},
});
// Handle field deleted audit log.
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED,
envelopeId: envelope.id,
metadata,
data: {
fieldId: deletedField.secondaryId,
fieldRecipientEmail: recipientWithFields.email,
fieldRecipientId: deletedField.recipientId,
fieldType: deletedField.type,
},
}),
});
}
return deletedField;
});
});

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZDeleteEnvelopeFieldRequestSchema = z.object({
fieldId: z.number(),
});
export const ZDeleteEnvelopeFieldResponseSchema = z.void();
export type TDeleteEnvelopeFieldRequest = z.infer<typeof ZDeleteEnvelopeFieldRequestSchema>;
export type TDeleteEnvelopeFieldResponse = z.infer<typeof ZDeleteEnvelopeFieldResponseSchema>;

View File

@ -0,0 +1,36 @@
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
import { authenticatedProcedure } from '../../trpc';
import {
ZGetEnvelopeFieldRequestSchema,
ZGetEnvelopeFieldResponseSchema,
} from './get-envelope-field.types';
export const getEnvelopeFieldRoute = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/envelope/field/{fieldId}',
summary: 'Get envelope field',
description: 'Returns an envelope field given an ID',
tags: ['Envelope Field'],
},
})
.input(ZGetEnvelopeFieldRequestSchema)
.output(ZGetEnvelopeFieldResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { fieldId } = input;
ctx.logger.info({
input: {
fieldId,
},
});
return await getFieldById({
userId: user.id,
teamId,
fieldId,
});
});

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
export const ZGetEnvelopeFieldRequestSchema = z.object({
fieldId: z.number(),
});
export const ZGetEnvelopeFieldResponseSchema = ZEnvelopeFieldSchema;
export type TGetEnvelopeFieldRequest = z.infer<typeof ZGetEnvelopeFieldRequestSchema>;
export type TGetEnvelopeFieldResponse = z.infer<typeof ZGetEnvelopeFieldResponseSchema>;

View File

@ -0,0 +1,42 @@
import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
import { authenticatedProcedure } from '../../trpc';
import {
ZUpdateEnvelopeFieldsRequestSchema,
ZUpdateEnvelopeFieldsResponseSchema,
} from './update-envelope-fields.types';
export const updateEnvelopeFieldsRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/field/update-many',
summary: 'Update envelope fields',
description: 'Update multiple envelope fields for an envelope',
tags: ['Envelope Field'],
},
})
.input(ZUpdateEnvelopeFieldsRequestSchema)
.output(ZUpdateEnvelopeFieldsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { envelopeId, data: fields } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
return await updateEnvelopeFields({
userId: user.id,
teamId,
id: {
type: 'envelopeId',
id: envelopeId,
},
type: null,
fields,
requestMetadata: ctx.metadata,
});
});

View File

@ -0,0 +1,40 @@
import { z } from 'zod';
import {
ZClampedFieldHeightSchema,
ZClampedFieldPageXSchema,
ZClampedFieldPageYSchema,
ZClampedFieldWidthSchema,
ZFieldPageNumberSchema,
ZFieldSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
const ZUpdateFieldSchema = ZFieldAndMetaSchema.and(
z.object({
id: z.number().describe('The ID of the field to update.'),
envelopeItemId: z
.string()
.optional()
.describe(
'The ID of the envelope item to put the field on. If not provided, field will be placed on the first item.',
),
pageNumber: ZFieldPageNumberSchema.optional(),
pageX: ZClampedFieldPageXSchema.optional(),
pageY: ZClampedFieldPageYSchema.optional(),
width: ZClampedFieldWidthSchema.optional(),
height: ZClampedFieldHeightSchema.optional(),
}),
);
export const ZUpdateEnvelopeFieldsRequestSchema = z.object({
envelopeId: z.string(),
data: ZUpdateFieldSchema.array(),
});
export const ZUpdateEnvelopeFieldsResponseSchema = z.object({
fields: z.array(ZFieldSchema),
});
export type TUpdateEnvelopeFieldsRequest = z.infer<typeof ZUpdateEnvelopeFieldsRequestSchema>;
export type TUpdateEnvelopeFieldsResponse = z.infer<typeof ZUpdateEnvelopeFieldsResponseSchema>;

View File

@ -0,0 +1,41 @@
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
import { authenticatedProcedure } from '../../trpc';
import {
ZCreateEnvelopeRecipientsRequestSchema,
ZCreateEnvelopeRecipientsResponseSchema,
} from './create-envelope-recipients.types';
export const createEnvelopeRecipientsRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/recipient/create-many',
summary: 'Create envelope recipients',
description: 'Create multiple recipients for an envelope',
tags: ['Envelope Recipients'],
},
})
.input(ZCreateEnvelopeRecipientsRequestSchema)
.output(ZCreateEnvelopeRecipientsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { envelopeId, data: recipients } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
return await createEnvelopeRecipients({
userId: user.id,
teamId,
id: {
type: 'envelopeId',
id: envelopeId,
},
recipients,
requestMetadata: metadata,
});
});

View File

@ -0,0 +1,21 @@
import { z } from 'zod';
import { ZEnvelopeRecipientLiteSchema } from '@documenso/lib/types/recipient';
import { ZCreateRecipientSchema } from '../../recipient-router/schema';
export const ZCreateEnvelopeRecipientsRequestSchema = z.object({
envelopeId: z.string(),
data: ZCreateRecipientSchema.array(),
});
export const ZCreateEnvelopeRecipientsResponseSchema = z.object({
recipients: ZEnvelopeRecipientLiteSchema.array(),
});
export type TCreateEnvelopeRecipientsRequest = z.infer<
typeof ZCreateEnvelopeRecipientsRequestSchema
>;
export type TCreateEnvelopeRecipientsResponse = z.infer<
typeof ZCreateEnvelopeRecipientsResponseSchema
>;

View File

@ -0,0 +1,37 @@
import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/delete-envelope-recipient';
import { authenticatedProcedure } from '../../trpc';
import {
ZDeleteEnvelopeRecipientRequestSchema,
ZDeleteEnvelopeRecipientResponseSchema,
} from './delete-envelope-recipient.types';
export const deleteEnvelopeRecipientRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/recipient/delete',
summary: 'Delete envelope recipient',
description: 'Delete an envelope recipient',
tags: ['Envelope Recipient'],
},
})
.input(ZDeleteEnvelopeRecipientRequestSchema)
.output(ZDeleteEnvelopeRecipientResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { recipientId } = input;
ctx.logger.info({
input: {
recipientId,
},
});
await deleteEnvelopeRecipient({
userId: user.id,
teamId,
recipientId,
requestMetadata: metadata,
});
});

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
export const ZDeleteEnvelopeRecipientRequestSchema = z.object({
recipientId: z.number(),
});
export const ZDeleteEnvelopeRecipientResponseSchema = z.void();
export type TDeleteEnvelopeRecipientRequest = z.infer<typeof ZDeleteEnvelopeRecipientRequestSchema>;
export type TDeleteEnvelopeRecipientResponse = z.infer<
typeof ZDeleteEnvelopeRecipientResponseSchema
>;

View File

@ -0,0 +1,52 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../../trpc';
import {
ZGetEnvelopeRecipientRequestSchema,
ZGetEnvelopeRecipientResponseSchema,
} from './get-envelope-recipient.types';
export const getEnvelopeRecipientRoute = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/envelope/recipient/{recipientId}',
summary: 'Get envelope recipient',
description: 'Returns an envelope recipient given an ID',
tags: ['Envelope Recipient'],
},
})
.input(ZGetEnvelopeRecipientRequestSchema)
.output(ZGetEnvelopeRecipientResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { recipientId } = input;
ctx.logger.info({
input: {
recipientId,
},
});
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelope: {
team: buildTeamWhereQuery({ teamId, userId: user.id }),
},
},
include: {
fields: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
return recipient;
});

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { ZEnvelopeRecipientSchema } from '@documenso/lib/types/recipient';
export const ZGetEnvelopeRecipientRequestSchema = z.object({
recipientId: z.number(),
});
export const ZGetEnvelopeRecipientResponseSchema = ZEnvelopeRecipientSchema;
export type TGetEnvelopeRecipientRequest = z.infer<typeof ZGetEnvelopeRecipientRequestSchema>;
export type TGetEnvelopeRecipientResponse = z.infer<typeof ZGetEnvelopeRecipientResponseSchema>;

View File

@ -0,0 +1,41 @@
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
import { authenticatedProcedure } from '../../trpc';
import {
ZUpdateEnvelopeRecipientsRequestSchema,
ZUpdateEnvelopeRecipientsResponseSchema,
} from './update-envelope-recipients.types';
export const updateEnvelopeRecipientsRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/recipient/update-many',
summary: 'Update envelope recipients',
description: 'Update multiple recipients for an envelope',
tags: ['Envelope Recipient'],
},
})
.input(ZUpdateEnvelopeRecipientsRequestSchema)
.output(ZUpdateEnvelopeRecipientsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { envelopeId, data: recipients } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
return await updateEnvelopeRecipients({
userId: user.id,
teamId,
id: {
type: 'envelopeId',
id: envelopeId,
},
recipients,
requestMetadata: ctx.metadata,
});
});

View File

@ -0,0 +1,21 @@
import { z } from 'zod';
import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
import { ZUpdateRecipientSchema } from '../../recipient-router/schema';
export const ZUpdateEnvelopeRecipientsRequestSchema = z.object({
envelopeId: z.string(),
data: ZUpdateRecipientSchema.array(),
});
export const ZUpdateEnvelopeRecipientsResponseSchema = z.object({
recipients: ZRecipientLiteSchema.array(),
});
export type TUpdateEnvelopeRecipientsRequest = z.infer<
typeof ZUpdateEnvelopeRecipientsRequestSchema
>;
export type TUpdateEnvelopeRecipientsResponse = z.infer<
typeof ZUpdateEnvelopeRecipientsResponseSchema
>;

View File

@ -4,7 +4,15 @@ import { authenticatedProcedure } from '../trpc';
import { ZGetEnvelopeRequestSchema, ZGetEnvelopeResponseSchema } from './get-envelope.types';
export const getEnvelopeRoute = authenticatedProcedure
// .meta(getEnvelopeMeta)
.meta({
openapi: {
method: 'GET',
path: '/envelope/{envelopeId}',
summary: 'Get envelope',
description: 'Returns an envelope given an ID',
tags: ['Envelope'],
},
})
.input(ZGetEnvelopeRequestSchema)
.output(ZGetEnvelopeResponseSchema)
.query(async ({ input, ctx }) => {

View File

@ -2,16 +2,6 @@ import { z } from 'zod';
import { ZEnvelopeSchema } from '@documenso/lib/types/envelope';
// export const getEnvelopeMeta: TrpcRouteMeta = {
// openapi: {
// method: 'GET',
// path: '/envelope/{envelopeId}',
// summary: 'Get envelope',
// description: 'Returns a envelope given an ID',
// tags: ['Envelope'],
// },
// };
export const ZGetEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
});

View File

@ -7,7 +7,16 @@ import {
} from './redistribute-envelope.types';
export const redistributeEnvelopeRoute = authenticatedProcedure
// .meta(redistributeEnvelopeMeta)
.meta({
openapi: {
method: 'POST',
path: '/envelope/redistribute',
summary: 'Redistribute envelope',
description:
'Redistribute the envelope to the provided recipients who have not actioned the envelope. Will use the distribution method set in the envelope',
tags: ['Envelope'],
},
})
.input(ZRedistributeEnvelopeRequestSchema)
.output(ZRedistributeEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -1,16 +1,5 @@
import { z } from 'zod';
// export const redistributeEnvelopeMeta: TrpcRouteMeta = {
// openapi: {
// method: 'POST',
// path: '/envelope/redistribute',
// summary: 'Redistribute document',
// description:
// 'Redistribute the document to the provided recipients who have not actioned the document. Will use the distribution method set in the document',
// tags: ['Envelope'],
// },
// };
export const ZRedistributeEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
recipients: z

View File

@ -9,6 +9,14 @@ import { deleteEnvelopeRoute } from './delete-envelope';
import { deleteEnvelopeItemRoute } from './delete-envelope-item';
import { distributeEnvelopeRoute } from './distribute-envelope';
import { duplicateEnvelopeRoute } from './duplicate-envelope';
import { createEnvelopeFieldsRoute } from './envelope-fields/create-envelope-fields';
import { deleteEnvelopeFieldRoute } from './envelope-fields/delete-envelope-field';
import { getEnvelopeFieldRoute } from './envelope-fields/get-envelope-field';
import { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fields';
import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients';
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
import { getEnvelopeRoute } from './get-envelope';
import { getEnvelopeItemsRoute } from './get-envelope-items';
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
@ -27,8 +35,6 @@ export const envelopeRouter = router({
duplicate: duplicateEnvelopeRoute,
distribute: distributeEnvelopeRoute,
redistribute: redistributeEnvelopeRoute,
// share: shareEnvelopeRoute,
item: {
getMany: getEnvelopeItemsRoute,
getManyByToken: getEnvelopeItemsByTokenRoute,
@ -37,9 +43,17 @@ export const envelopeRouter = router({
delete: deleteEnvelopeItemRoute,
},
recipient: {
get: getEnvelopeRecipientRoute,
createMany: createEnvelopeRecipientsRoute,
updateMany: updateEnvelopeRecipientsRoute,
delete: deleteEnvelopeRecipientRoute,
set: setEnvelopeRecipientsRoute,
},
field: {
get: getEnvelopeFieldRoute,
createMany: createEnvelopeFieldsRoute,
updateMany: updateEnvelopeFieldsRoute,
delete: deleteEnvelopeFieldRoute,
set: setEnvelopeFieldsRoute,
sign: signEnvelopeFieldRoute,
},

View File

@ -1,6 +1,12 @@
import { EnvelopeType, FieldType } from '@prisma/client';
import { z } from 'zod';
import {
ZClampedFieldHeightSchema,
ZClampedFieldPageXSchema,
ZClampedFieldPageYSchema,
ZClampedFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
export const ZSetEnvelopeFieldsRequestSchema = z.object({
@ -20,28 +26,11 @@ export const ZSetEnvelopeFieldsRequestSchema = z.object({
.number()
.min(1)
.describe('The page number of the field on the envelope. Starts from 1.'),
// Todo: Envelopes - Extract these 0-100 schemas with better descriptions.
positionX: z
.number()
.min(0)
.max(100)
.describe('The percentage based X position of the field on the envelope.'),
positionY: z
.number()
.min(0)
.max(100)
.describe('The percentage based Y position of the field on the envelope.'),
width: z
.number()
.min(0)
.max(100)
.describe('The percentage based width of the field on the envelope.'),
height: z
.number()
.min(0)
.max(100)
.describe('The percentage based height of the field on the envelope.'),
fieldMeta: ZFieldMetaSchema, // Todo: Envelopes - Use a more strict form?
positionX: ZClampedFieldPageXSchema,
positionY: ZClampedFieldPageYSchema,
width: ZClampedFieldWidthSchema,
height: ZClampedFieldHeightSchema,
fieldMeta: ZFieldMetaSchema,
}),
),
});

View File

@ -10,6 +10,15 @@ import {
} from './update-envelope-items.types';
export const updateEnvelopeItemsRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/item/update-many',
summary: 'Update envelope items',
description: 'Update multiple envelope items for an envelope',
tags: ['Envelope Item'],
},
})
.input(ZUpdateEnvelopeItemsRequestSchema)
.output(ZUpdateEnvelopeItemsResponseSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -7,7 +7,14 @@ import {
} from './update-envelope.types';
export const updateEnvelopeRoute = authenticatedProcedure
// .meta(updateEnvelopeTrpcMeta)
.meta({
openapi: {
method: 'POST',
path: '/envelope/update',
summary: 'Update envelope',
tags: ['Envelope'],
},
})
.input(ZUpdateEnvelopeRequestSchema)
.output(ZUpdateEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -15,15 +15,6 @@ import {
ZDocumentVisibilitySchema,
} from '../document-router/schema';
// export const updateEnvelopeMeta: TrpcRouteMeta = {
// openapi: {
// method: 'POST',
// path: '/envelope/update',
// summary: 'Update envelope',
// tags: ['Envelope'],
// },
// };
export const ZUpdateEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
envelopeType: z.nativeEnum(EnvelopeType),

View File

@ -8,8 +8,7 @@ import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/rem
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { updateDocumentFields } from '@documenso/lib/server-only/field/update-document-fields';
import { updateTemplateFields } from '@documenso/lib/server-only/field/update-template-fields';
import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
import { authenticatedProcedure, procedure, router } from '../trpc';
@ -178,10 +177,14 @@ export const fieldRouter = router({
},
});
const updatedFields = await updateDocumentFields({
const updatedFields = await updateEnvelopeFields({
userId: ctx.user.id,
teamId,
documentId,
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
fields: [field],
requestMetadata: ctx.metadata,
});
@ -214,10 +217,14 @@ export const fieldRouter = router({
},
});
return await updateDocumentFields({
return await updateEnvelopeFields({
userId: ctx.user.id,
teamId,
documentId,
id: {
type: 'documentId',
id: documentId,
},
type: EnvelopeType.DOCUMENT,
fields,
requestMetadata: ctx.metadata,
});
@ -431,11 +438,16 @@ export const fieldRouter = router({
},
});
const updatedFields = await updateTemplateFields({
const updatedFields = await updateEnvelopeFields({
userId: ctx.user.id,
teamId,
templateId,
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
fields: [field],
requestMetadata: ctx.metadata,
});
return updatedFields.fields[0];
@ -466,11 +478,16 @@ export const fieldRouter = router({
},
});
return await updateTemplateFields({
return await updateEnvelopeFields({
userId: ctx.user.id,
teamId,
templateId,
id: {
type: 'templateId',
id: templateId,
},
type: EnvelopeType.TEMPLATE,
fields,
requestMetadata: ctx.metadata,
});
}),

View File

@ -2,15 +2,12 @@ import { EnvelopeType } from '@prisma/client';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token';
import { createDocumentRecipients } from '@documenso/lib/server-only/recipient/create-document-recipients';
import { createTemplateRecipients } from '@documenso/lib/server-only/recipient/create-template-recipients';
import { deleteDocumentRecipient } from '@documenso/lib/server-only/recipient/delete-document-recipient';
import { deleteTemplateRecipient } from '@documenso/lib/server-only/recipient/delete-template-recipient';
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/delete-envelope-recipient';
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients';
import { updateTemplateRecipients } from '@documenso/lib/server-only/recipient/update-template-recipients';
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
import { authenticatedProcedure, procedure, router } from '../trpc';
@ -108,7 +105,7 @@ export const recipientRouter = router({
},
});
const createdRecipients = await createDocumentRecipients({
const createdRecipients = await createEnvelopeRecipients({
userId: ctx.user.id,
teamId,
id: {
@ -147,7 +144,7 @@ export const recipientRouter = router({
},
});
return await createDocumentRecipients({
return await createEnvelopeRecipients({
userId: ctx.user.id,
teamId,
id: {
@ -184,7 +181,7 @@ export const recipientRouter = router({
},
});
const updatedRecipients = await updateDocumentRecipients({
const updatedRecipients = await updateEnvelopeRecipients({
userId: ctx.user.id,
teamId,
id: {
@ -223,7 +220,7 @@ export const recipientRouter = router({
},
});
return await updateDocumentRecipients({
return await updateEnvelopeRecipients({
userId: ctx.user.id,
teamId,
id: {
@ -259,7 +256,7 @@ export const recipientRouter = router({
},
});
await deleteDocumentRecipient({
await deleteEnvelopeRecipient({
userId: ctx.user.id,
teamId,
recipientId,
@ -363,11 +360,15 @@ export const recipientRouter = router({
},
});
const createdRecipients = await createTemplateRecipients({
const createdRecipients = await createEnvelopeRecipients({
userId: ctx.user.id,
teamId,
templateId,
id: {
id: templateId,
type: 'templateId',
},
recipients: [recipient],
requestMetadata: ctx.metadata,
});
return createdRecipients.recipients[0];
@ -398,11 +399,15 @@ export const recipientRouter = router({
},
});
return await createTemplateRecipients({
return await createEnvelopeRecipients({
userId: ctx.user.id,
teamId,
templateId,
id: {
id: templateId,
type: 'templateId',
},
recipients,
requestMetadata: ctx.metadata,
});
}),
@ -431,11 +436,15 @@ export const recipientRouter = router({
},
});
const updatedRecipients = await updateTemplateRecipients({
const updatedRecipients = await updateEnvelopeRecipients({
userId: ctx.user.id,
teamId,
templateId,
id: {
type: 'templateId',
id: templateId,
},
recipients: [recipient],
requestMetadata: ctx.metadata,
});
return updatedRecipients.recipients[0];
@ -466,11 +475,15 @@ export const recipientRouter = router({
},
});
return await updateTemplateRecipients({
return await updateEnvelopeRecipients({
userId: ctx.user.id,
teamId,
templateId,
id: {
type: 'templateId',
id: templateId,
},
recipients,
requestMetadata: ctx.metadata,
});
}),
@ -498,10 +511,11 @@ export const recipientRouter = router({
},
});
await deleteTemplateRecipient({
await deleteEnvelopeRecipient({
recipientId,
userId: ctx.user.id,
teamId,
requestMetadata: ctx.metadata,
});
return ZGenericSuccessResponse;

View File

@ -519,7 +519,6 @@ export const templateRouter = router({
directTemplateExternalId,
signedFieldValues,
templateUpdatedAt,
nextSigner,
} = input;
ctx.logger.info({
@ -542,7 +541,6 @@ export const templateRouter = router({
email: ctx.user.email,
}
: undefined,
nextSigner,
requestMetadata: ctx.metadata,
});
}),

View File

@ -90,12 +90,6 @@ export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({
directTemplateExternalId: z.string().optional(),
signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema),
templateUpdatedAt: z.date(),
nextSigner: z
.object({
email: z.string().email().max(254),
name: z.string().min(1).max(255),
})
.optional(),
});
export const ZCreateDocumentFromTemplateRequestSchema = z.object({